israeli-banks-actual-budget-importer 1.8.1 → 1.10.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/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [1.10.0](https://github.com/tomerh2001/israeli-banks-actual-budget-importer/compare/v1.9.0...v1.10.0) (2026-01-04)
2
+
3
+
4
+ ### Features
5
+
6
+ * enhance reconciliation options and update configuration schema ([2d873a5](https://github.com/tomerh2001/israeli-banks-actual-budget-importer/commit/2d873a542915be3d35ac7ab36003d4617d6256c4))
7
+
8
+ # [1.9.0](https://github.com/tomerh2001/israeli-banks-actual-budget-importer/compare/v1.8.1...v1.9.0) (2026-01-03)
9
+
10
+
11
+ ### Features
12
+
13
+ * added support for bank hapoalim investment accounts ([f1017d8](https://github.com/tomerh2001/israeli-banks-actual-budget-importer/commit/f1017d8b9eae21c8a99d92389e6bd3b49f1cf7aa))
14
+
1
15
  ## [1.8.1](https://github.com/tomerh2001/israeli-banks-actual-budget-importer/compare/v1.8.0...v1.8.1) (2026-01-03)
2
16
 
3
17
 
package/README.md CHANGED
@@ -9,22 +9,29 @@ This project provides an importer from Israeli banks (via [israeli-bank-scrapers
9
9
 
10
10
  ## Features
11
11
 
12
- 1. **Multi Bank Support**: Supports all of the institutions that the [israeli-bank-scrapers](https://github.com/eshaham/israeli-bank-scrapers) library covers (Bank Hapoalim, Cal, Leumi, Discount, etc.).
12
+ 1. **Multi Bank Support**
13
+ Supports all of the institutions that the [israeli-bank-scrapers](https://github.com/eshaham/israeli-bank-scrapers) library covers (Bank Hapoalim, Cal, Leumi, Discount, etc.).
13
14
 
14
- 2. **Prevents duplicate transactions** using Actual’s [`imported_id`](https://actualbudget.org/docs/api/reference/#transactions) logic.
15
+ 2. **Prevents duplicate transactions**
16
+ Uses Actual’s [`imported_id`](https://actualbudget.org/docs/api/reference/#transactions) logic.
15
17
 
16
- 3. **Automatic Account Creation**: If the bank account does not exist in Actual, it will be created automatically.
18
+ 3. **Automatic Account Creation**
19
+ If the bank account does not exist in Actual, it will be created automatically.
17
20
 
18
- 4. **Reconciliation:** Optional reconciliation to adjust account balances automatically.
21
+ 4. **Reconciliation**
22
+ Optional reconciliation to adjust account balances automatically.
19
23
 
20
- 5. **Credit Card / Multi-Account Mapping (Targets):** Supports mapping multiple scraped accounts/cards into one Actual account, or mapping each scraped card into its own Actual account (via `targets` and `accounts`).
24
+ 5. **Credit Card / Multi-Account Mapping (Targets)**
25
+ Supports mapping multiple scraped accounts/cards into one Actual account, or mapping each scraped card into its own Actual account (via `targets` and `accounts`).
21
26
 
22
- 6. **Concurrent Processing:** Uses a queue (via [p-queue](https://www.npmjs.com/package/p-queue)) to manage scraping tasks concurrently.
27
+ 6. **Concurrent Processing**
28
+ Uses a queue (via [p-queue](https://www.npmjs.com/package/p-queue)) to manage scraping tasks concurrently.
23
29
 
24
30
  ## Installation
25
31
 
26
32
  ### Docker
27
33
  https://hub.docker.com/r/tomerh2001/israeli-banks-actual-budget-importer
34
+
28
35
  #### Example
29
36
  ```yml
30
37
  services:
@@ -44,18 +51,18 @@ services:
44
51
 
45
52
  ## Configuration
46
53
 
47
- The application configuration is defined using JSON and validated against a schema.
54
+ The application configuration is defined using JSON and validated against a schema.
48
55
  The main configuration file is `config.json`.
49
56
 
50
57
  The configuration has **two independent top-level sections**:
51
- 1. `actual`: Configures the Actual Budget connection.
52
- 2. `banks`: Configures bank scrapers and account mappings.
58
+ 1. `actual` Configures the Actual Budget connection.
59
+ 2. `banks` Configures bank scrapers and account mappings.
53
60
 
54
61
  ---
55
62
 
56
- ### 1) `actual` configuration
63
+ ### 1) `actual` section
57
64
 
58
- This section configures the connection to your Actual Budget server and budget.
65
+ This section configures the connection to your Actual Budget server and budget.
59
66
  It is **always required**, regardless of how you configure banks or targets.
60
67
 
61
68
  ```json
@@ -78,7 +85,7 @@ Nothing in this block changes when using `targets`, credit cards, or multi-accou
78
85
 
79
86
  ---
80
87
 
81
- ### 2) `banks` configuration
88
+ ### 2) `banks` section
82
89
 
83
90
  The `banks` section defines:
84
91
  - Which banks to scrape
@@ -86,11 +93,13 @@ The `banks` section defines:
86
93
  - How scraped accounts/cards are mapped into Actual accounts
87
94
 
88
95
  Each bank entry includes the credentials required by `israeli-bank-scrapers`
89
- (e.g. `userCode`, `username`, `password`, etc.) and supports **multiple mapping modes**.
96
+ (e.g. `userCode`, `username`, `password`, etc.).
97
+
98
+ ---
90
99
 
91
- #### Using `targets` (recommended)
100
+ ### `targets` sub-section
92
101
 
93
- A single bank scrape (for example `visaCal`) may return **multiple accounts/cards**.
102
+ A single bank scrape (for example `visaCal`) may return **multiple accounts/cards**.
94
103
  Different users model these differently in Actual, so the importer supports `targets`.
95
104
 
96
105
  Each **target** represents:
@@ -99,12 +108,21 @@ Each **target** represents:
99
108
 
100
109
  For each target:
101
110
  - Imported transactions = concatenation of transactions from selected cards
102
- - Reconciliation (if enabled) = sum of balances of selected cards
111
+ - Reconciliation (if enabled) = sum of balances of selected cards
103
112
  (only cards with a valid numeric balance are included)
104
113
 
105
114
  ---
106
115
 
107
- #### Example A: One Actual account for all VisaCal cards (consolidated)
116
+ ### Reconciliation behavior
117
+
118
+ - Reconciliation is controlled by the `reconcile` boolean.
119
+ - When `reconcile: true`, **a new reconciliation transaction is created on every run** (no updates, no reconciliation).
120
+ - Existing reconciliation transactions are never modified or reused.
121
+ - If `reconcile` is omitted or set to `false`, no reconciliation transaction is created.
122
+
123
+ ---
124
+
125
+ ### Example A: One Actual account for all VisaCal cards
108
126
 
109
127
  ```json
110
128
  {
@@ -137,7 +155,7 @@ For each target:
137
155
 
138
156
  ---
139
157
 
140
- #### Example B: One Actual account per VisaCal card (separate accounts)
158
+ ### Example B: One Actual account per VisaCal card
141
159
 
142
160
  ```json
143
161
  {
@@ -175,7 +193,7 @@ For each target:
175
193
 
176
194
  ---
177
195
 
178
- #### Example C: Grouped cards into a single Actual account (subset)
196
+ ### Example C: Grouped cards into a single Actual account (subset)
179
197
 
180
198
  ```json
181
199
  {
@@ -210,7 +228,7 @@ For each target:
210
228
 
211
229
  ## Legacy configuration (single Actual account per bank)
212
230
 
213
- This configuration style is **fully supported for backward compatibility**,
231
+ This configuration style is **fully supported for backward compatibility**,
214
232
  but does **not** allow fine-grained control over multiple cards/accounts.
215
233
 
216
234
  It maps all scraped accounts from the bank into a single Actual account.
@@ -261,4 +279,4 @@ This project is open-source. Please see the [LICENSE](./LICENSE) file for licens
261
279
 
262
280
  - **israeli-bank-scrapers:** Thanks to the contributors of the bank scraper libraries.
263
281
  - **Actual App:** For providing a powerful budgeting API.
264
- - **Open-source Community:** Your support and contributions are appreciated.
282
+ - **Open-source Community:** Your support and contributions are appreciated.
@@ -50,83 +50,205 @@
50
50
  ],
51
51
  "type": "object"
52
52
  },
53
+ "AccountsSelector": {
54
+ "anyOf": [
55
+ {
56
+ "const": "all"
57
+ },
58
+ {
59
+ "type": "array",
60
+ "items": {
61
+ "type": "string"
62
+ },
63
+ "minItems": 1
64
+ }
65
+ ]
66
+ },
67
+ "ReconcileOption": {
68
+ "anyOf": [
69
+ {
70
+ "type": "boolean"
71
+ },
72
+ {
73
+ "const": "consolidate"
74
+ }
75
+ ]
76
+ },
77
+ "ConfigBankTarget": {
78
+ "additionalProperties": false,
79
+ "properties": {
80
+ "actualAccountId": {
81
+ "type": "string"
82
+ },
83
+ "reconcile": {
84
+ "$ref": "#/definitions/ReconcileOption"
85
+ },
86
+ "accounts": {
87
+ "$ref": "#/definitions/AccountsSelector"
88
+ }
89
+ },
90
+ "required": [
91
+ "actualAccountId"
92
+ ],
93
+ "type": "object"
94
+ },
95
+ "ConfigBankMappingLegacy": {
96
+ "type": "object",
97
+ "properties": {
98
+ "actualAccountId": {
99
+ "type": "string"
100
+ },
101
+ "reconcile": {
102
+ "$ref": "#/definitions/ReconcileOption"
103
+ }
104
+ },
105
+ "required": [
106
+ "actualAccountId"
107
+ ]
108
+ },
109
+ "ConfigBankMappingTargets": {
110
+ "type": "object",
111
+ "properties": {
112
+ "targets": {
113
+ "type": "array",
114
+ "items": {
115
+ "$ref": "#/definitions/ConfigBankTarget"
116
+ },
117
+ "minItems": 1
118
+ }
119
+ },
120
+ "required": [
121
+ "targets"
122
+ ]
123
+ },
53
124
  "ConfigBank": {
54
125
  "anyOf": [
55
126
  {
56
127
  "additionalProperties": false,
57
128
  "properties": {
58
- "actualAccountId": {
129
+ "userCode": {
59
130
  "type": "string"
60
131
  },
61
132
  "password": {
62
133
  "type": "string"
63
134
  },
135
+ "actualAccountId": {
136
+ "type": "string"
137
+ },
64
138
  "reconcile": {
65
- "type": "boolean"
139
+ "$ref": "#/definitions/ReconcileOption"
66
140
  },
67
- "userCode": {
68
- "type": "string"
141
+ "targets": {
142
+ "type": "array",
143
+ "items": {
144
+ "$ref": "#/definitions/ConfigBankTarget"
145
+ },
146
+ "minItems": 1
69
147
  }
70
148
  },
71
149
  "required": [
72
- "actualAccountId",
73
150
  "password",
74
151
  "userCode"
75
152
  ],
153
+ "allOf": [
154
+ {
155
+ "anyOf": [
156
+ {
157
+ "$ref": "#/definitions/ConfigBankMappingLegacy"
158
+ },
159
+ {
160
+ "$ref": "#/definitions/ConfigBankMappingTargets"
161
+ }
162
+ ]
163
+ }
164
+ ],
76
165
  "type": "object"
77
166
  },
78
167
  {
79
168
  "additionalProperties": false,
80
169
  "properties": {
81
- "actualAccountId": {
170
+ "username": {
82
171
  "type": "string"
83
172
  },
84
173
  "password": {
85
174
  "type": "string"
86
175
  },
176
+ "actualAccountId": {
177
+ "type": "string"
178
+ },
87
179
  "reconcile": {
88
- "type": "boolean"
180
+ "$ref": "#/definitions/ReconcileOption"
89
181
  },
90
- "username": {
91
- "type": "string"
182
+ "targets": {
183
+ "type": "array",
184
+ "items": {
185
+ "$ref": "#/definitions/ConfigBankTarget"
186
+ },
187
+ "minItems": 1
92
188
  }
93
189
  },
94
190
  "required": [
95
- "actualAccountId",
96
191
  "password",
97
192
  "username"
98
193
  ],
194
+ "allOf": [
195
+ {
196
+ "anyOf": [
197
+ {
198
+ "$ref": "#/definitions/ConfigBankMappingLegacy"
199
+ },
200
+ {
201
+ "$ref": "#/definitions/ConfigBankMappingTargets"
202
+ }
203
+ ]
204
+ }
205
+ ],
99
206
  "type": "object"
100
207
  },
101
208
  {
102
209
  "additionalProperties": false,
103
210
  "properties": {
104
- "actualAccountId": {
105
- "type": "string"
106
- },
107
211
  "id": {
108
212
  "type": "string"
109
213
  },
110
214
  "password": {
111
215
  "type": "string"
112
216
  },
217
+ "actualAccountId": {
218
+ "type": "string"
219
+ },
113
220
  "reconcile": {
114
- "type": "boolean"
221
+ "$ref": "#/definitions/ReconcileOption"
222
+ },
223
+ "targets": {
224
+ "type": "array",
225
+ "items": {
226
+ "$ref": "#/definitions/ConfigBankTarget"
227
+ },
228
+ "minItems": 1
115
229
  }
116
230
  },
117
231
  "required": [
118
- "actualAccountId",
119
232
  "id",
120
233
  "password"
121
234
  ],
235
+ "allOf": [
236
+ {
237
+ "anyOf": [
238
+ {
239
+ "$ref": "#/definitions/ConfigBankMappingLegacy"
240
+ },
241
+ {
242
+ "$ref": "#/definitions/ConfigBankMappingTargets"
243
+ }
244
+ ]
245
+ }
246
+ ],
122
247
  "type": "object"
123
248
  },
124
249
  {
125
250
  "additionalProperties": false,
126
251
  "properties": {
127
- "actualAccountId": {
128
- "type": "string"
129
- },
130
252
  "id": {
131
253
  "type": "string"
132
254
  },
@@ -136,109 +258,181 @@
136
258
  "password": {
137
259
  "type": "string"
138
260
  },
261
+ "actualAccountId": {
262
+ "type": "string"
263
+ },
139
264
  "reconcile": {
140
- "type": "boolean"
265
+ "$ref": "#/definitions/ReconcileOption"
266
+ },
267
+ "targets": {
268
+ "type": "array",
269
+ "items": {
270
+ "$ref": "#/definitions/ConfigBankTarget"
271
+ },
272
+ "minItems": 1
141
273
  }
142
274
  },
143
275
  "required": [
144
- "actualAccountId",
145
276
  "id",
146
277
  "num",
147
278
  "password"
148
279
  ],
280
+ "allOf": [
281
+ {
282
+ "anyOf": [
283
+ {
284
+ "$ref": "#/definitions/ConfigBankMappingLegacy"
285
+ },
286
+ {
287
+ "$ref": "#/definitions/ConfigBankMappingTargets"
288
+ }
289
+ ]
290
+ }
291
+ ],
149
292
  "type": "object"
150
293
  },
151
294
  {
152
295
  "additionalProperties": false,
153
296
  "properties": {
154
- "actualAccountId": {
297
+ "id": {
155
298
  "type": "string"
156
299
  },
157
300
  "card6Digits": {
158
301
  "type": "string"
159
302
  },
160
- "id": {
303
+ "password": {
161
304
  "type": "string"
162
305
  },
163
- "password": {
306
+ "actualAccountId": {
164
307
  "type": "string"
165
308
  },
166
309
  "reconcile": {
167
- "type": "boolean"
310
+ "$ref": "#/definitions/ReconcileOption"
311
+ },
312
+ "targets": {
313
+ "type": "array",
314
+ "items": {
315
+ "$ref": "#/definitions/ConfigBankTarget"
316
+ },
317
+ "minItems": 1
168
318
  }
169
319
  },
170
320
  "required": [
171
- "actualAccountId",
172
- "card6Digits",
173
321
  "id",
322
+ "card6Digits",
174
323
  "password"
175
324
  ],
325
+ "allOf": [
326
+ {
327
+ "anyOf": [
328
+ {
329
+ "$ref": "#/definitions/ConfigBankMappingLegacy"
330
+ },
331
+ {
332
+ "$ref": "#/definitions/ConfigBankMappingTargets"
333
+ }
334
+ ]
335
+ }
336
+ ],
176
337
  "type": "object"
177
338
  },
178
339
  {
179
340
  "additionalProperties": false,
180
341
  "properties": {
181
- "actualAccountId": {
342
+ "nationalID": {
182
343
  "type": "string"
183
344
  },
184
- "nationalID": {
345
+ "username": {
185
346
  "type": "string"
186
347
  },
187
348
  "password": {
188
349
  "type": "string"
189
350
  },
351
+ "actualAccountId": {
352
+ "type": "string"
353
+ },
190
354
  "reconcile": {
191
- "type": "boolean"
355
+ "$ref": "#/definitions/ReconcileOption"
192
356
  },
193
- "username": {
194
- "type": "string"
357
+ "targets": {
358
+ "type": "array",
359
+ "items": {
360
+ "$ref": "#/definitions/ConfigBankTarget"
361
+ },
362
+ "minItems": 1
195
363
  }
196
364
  },
197
365
  "required": [
198
- "actualAccountId",
199
366
  "nationalID",
200
- "password",
201
- "username"
367
+ "username",
368
+ "password"
369
+ ],
370
+ "allOf": [
371
+ {
372
+ "anyOf": [
373
+ {
374
+ "$ref": "#/definitions/ConfigBankMappingLegacy"
375
+ },
376
+ {
377
+ "$ref": "#/definitions/ConfigBankMappingTargets"
378
+ }
379
+ ]
380
+ }
202
381
  ],
203
382
  "type": "object"
204
383
  },
205
384
  {
206
385
  "additionalProperties": false,
207
386
  "properties": {
208
- "actualAccountId": {
209
- "type": "string"
210
- },
211
387
  "email": {
212
388
  "type": "string"
213
389
  },
214
- "otpCodeRetriever": {
215
- "$comment": "() => Promise<string>"
390
+ "phoneNumber": {
391
+ "type": "string"
216
392
  },
217
393
  "password": {
218
394
  "type": "string"
219
395
  },
220
- "phoneNumber": {
396
+ "otpCodeRetriever": {
397
+ "$comment": "() => Promise<string>"
398
+ },
399
+ "actualAccountId": {
221
400
  "type": "string"
222
401
  },
223
402
  "reconcile": {
224
- "type": "boolean"
403
+ "$ref": "#/definitions/ReconcileOption"
404
+ },
405
+ "targets": {
406
+ "type": "array",
407
+ "items": {
408
+ "$ref": "#/definitions/ConfigBankTarget"
409
+ },
410
+ "minItems": 1
225
411
  }
226
412
  },
227
413
  "required": [
228
- "actualAccountId",
229
414
  "email",
230
415
  "otpCodeRetriever",
231
416
  "password",
232
417
  "phoneNumber"
233
418
  ],
419
+ "allOf": [
420
+ {
421
+ "anyOf": [
422
+ {
423
+ "$ref": "#/definitions/ConfigBankMappingLegacy"
424
+ },
425
+ {
426
+ "$ref": "#/definitions/ConfigBankMappingTargets"
427
+ }
428
+ ]
429
+ }
430
+ ],
234
431
  "type": "object"
235
432
  },
236
433
  {
237
434
  "additionalProperties": false,
238
435
  "properties": {
239
- "actualAccountId": {
240
- "type": "string"
241
- },
242
436
  "email": {
243
437
  "type": "string"
244
438
  },
@@ -248,16 +442,37 @@
248
442
  "password": {
249
443
  "type": "string"
250
444
  },
445
+ "actualAccountId": {
446
+ "type": "string"
447
+ },
251
448
  "reconcile": {
252
- "type": "boolean"
449
+ "$ref": "#/definitions/ReconcileOption"
450
+ },
451
+ "targets": {
452
+ "type": "array",
453
+ "items": {
454
+ "$ref": "#/definitions/ConfigBankTarget"
455
+ },
456
+ "minItems": 1
253
457
  }
254
458
  },
255
459
  "required": [
256
- "actualAccountId",
257
460
  "email",
258
461
  "otpLongTermToken",
259
462
  "password"
260
463
  ],
464
+ "allOf": [
465
+ {
466
+ "anyOf": [
467
+ {
468
+ "$ref": "#/definitions/ConfigBankMappingLegacy"
469
+ },
470
+ {
471
+ "$ref": "#/definitions/ConfigBankMappingTargets"
472
+ }
473
+ ]
474
+ }
475
+ ],
261
476
  "type": "object"
262
477
  }
263
478
  ]
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.8.1",
2
+ "version": "1.10.0",
3
3
  "name": "israeli-banks-actual-budget-importer",
4
4
  "module": "index.ts",
5
5
  "type": "module",
@@ -25,8 +25,8 @@
25
25
  },
26
26
  "packageManager": "yarn@4.12.0",
27
27
  "dependencies": {
28
- "@actual-app/api": "^25.12.0",
29
- "@tomerh2001/israeli-bank-scrapers": "latest",
28
+ "@actual-app/api": "^26.1.0",
29
+ "@tomerh2001/israeli-bank-scrapers": "6.4.0",
30
30
  "cronstrue": "^3.9.0",
31
31
  "lodash": "^4.17.21",
32
32
  "moment": "^2.30.1",
package/src/config.d.ts CHANGED
@@ -17,6 +17,8 @@ export type ConfigActualBudget = {
17
17
  };
18
18
 
19
19
  export type ConfigBanks = Partial<Record<CompanyTypes, ConfigBank>>;
20
+ export type AccountsSelector = string[] | 'all';
21
+ export type ReconcileSelector = boolean | 'consolidate';
20
22
 
21
23
  /**
22
24
  * A single "import target" inside Actual.
@@ -30,8 +32,11 @@ export type ConfigBankTarget = {
30
32
 
31
33
  /**
32
34
  * If true, create/update a reconciliation transaction to match the scraped balance.
35
+ * If 'consolidate', reconcile once per Actual account using the consolidated balance
36
+ * across all selected scraped accounts.
37
+ * If false/undefined, do not reconcile.
33
38
  */
34
- reconcile?: boolean;
39
+ reconcile?: ReconcileSelector;
35
40
 
36
41
  /**
37
42
  * Which scraped accounts (by accountNumber) should be included in this target.
@@ -42,7 +47,7 @@ export type ConfigBankTarget = {
42
47
  * - treat as "all" for import, and for reconciliation use the first usable balance
43
48
  * (you'll refine this in the implementation files).
44
49
  */
45
- accounts?: 'all' | string[];
50
+ accounts?: AccountsSelector;
46
51
  };
47
52
 
48
53
  /**
@@ -61,5 +66,5 @@ export type ConfigBank = ScraperCredentials & {
61
66
  * If targets is provided, these should be ignored by runtime logic.
62
67
  */
63
68
  actualAccountId?: string;
64
- reconcile?: boolean;
69
+ reconcile?: ReconcileSelector;
65
70
  };
@@ -1,3 +1,6 @@
1
+ /* eslint-disable @typescript-eslint/no-unsafe-return */
2
+ /* eslint-disable no-await-in-loop */
3
+ // Importer.ts
1
4
  /* eslint-disable @typescript-eslint/no-unsafe-argument */
2
5
  /* eslint-disable @typescript-eslint/no-unsafe-assignment */
3
6
  /* eslint-disable @typescript-eslint/naming-convention */
@@ -8,73 +11,18 @@ import {createScraper, type ScraperCredentials} from '@tomerh2001/israeli-bank-s
8
11
  import _ from 'lodash';
9
12
  import moment from 'moment';
10
13
  import actual from '@actual-app/api';
11
- import {type PayeeEntity, type TransactionEntity} from '@actual-app/api/@types/loot-core/src/types/models';
14
+ import {type PayeeEntity} from '@actual-app/api/@types/loot-core/src/types/models';
12
15
  import stdout from 'mute-stdout';
13
- import {type ScrapeTransactionsContext} from './utils.d';
14
-
15
- // If you exported these from your config types file, import them from there instead.
16
- type AccountsSelector = 'all' | string[];
17
- type BankTarget = {
18
- actualAccountId: string;
19
- reconcile?: boolean;
20
- accounts?: AccountsSelector;
21
- };
22
-
23
- function isFiniteNumber(x: unknown): x is number {
24
- return typeof x === 'number' && Number.isFinite(x);
25
- }
26
-
27
- function stripUndefined<T extends Record<string, any>>(object: T): T {
28
- return Object.fromEntries(Object.entries(object).filter(([, v]) => v !== undefined)) as T;
29
- }
30
-
31
- function normalizeTargets(bank: any): BankTarget[] {
32
- // New config: targets[]
33
- if (Array.isArray(bank?.targets) && bank.targets.length > 0) {
34
- return bank.targets
35
- .filter((t: any) => t?.actualAccountId)
36
- .map((t: any) => ({
37
- actualAccountId: t.actualAccountId,
38
- reconcile: Boolean(t.reconcile),
39
- accounts: t.accounts,
40
- }));
41
- }
42
-
43
- // Legacy config: actualAccountId + reconcile
44
- if (bank?.actualAccountId) {
45
- return [{
46
- actualAccountId: bank.actualAccountId,
47
- reconcile: Boolean(bank.reconcile),
48
- // Legacy behavior did not support selecting accounts; treat as "all".
49
- accounts: 'all',
50
- }];
51
- }
52
-
53
- return [];
54
- }
55
-
56
- function selectScraperAccounts(
57
- allAccounts: any[] | undefined,
58
- selector: AccountsSelector | undefined,
59
- ) {
60
- const accounts = allAccounts ?? [];
61
- if (selector === undefined || selector === 'all') {
62
- return accounts;
63
- }
64
-
65
- const set = new Set(selector);
66
- return accounts.filter(a => set.has(String(a.accountNumber)));
67
- }
68
-
69
- function stableImportedId(companyId: string, accountNumber: string | undefined, txn: any) {
70
- // Prefer scraper identifier if present; fall back to a deterministic composite.
71
- const idPart
72
- = txn?.identifier
73
- ?? `${moment(txn?.date).format('YYYY-MM-DD')}:${txn?.chargedAmount}:${txn?.description ?? ''}:${txn?.memo ?? ''}`;
74
-
75
- // AccountNumber is important once multiple cards are aggregated into one Actual account.
76
- return `${companyId}:${accountNumber ?? 'unknown'}:${idPart}`;
77
- }
16
+ import {type ScrapeTransactionsContext} from './importer.d';
17
+ import {
18
+ normalizeTargets,
19
+ selectScraperAccounts,
20
+ stripUndefined,
21
+ stableImportedId,
22
+ isFiniteNumber,
23
+ reconciliationTargetKey,
24
+ uniqueReconciliationImportedId,
25
+ } from './importer.utils';
78
26
 
79
27
  export async function scrapeAndImportTransactions({companyId, bank}: ScrapeTransactionsContext) {
80
28
  function log(status: any, other?: Record<string, unknown>) {
@@ -118,7 +66,7 @@ export async function scrapeAndImportTransactions({companyId, bank}: ScrapeTrans
118
66
 
119
67
  const payees: PayeeEntity[] = await actual.getPayees();
120
68
 
121
- // Process each target independently (supports per-card, per-company, and consolidated).
69
+ // Process each target independently.
122
70
  for (const target of targets) {
123
71
  const selectedAccounts = selectScraperAccounts(result.accounts as any[], target.accounts);
124
72
 
@@ -156,6 +104,7 @@ export async function scrapeAndImportTransactions({companyId, bank}: ScrapeTrans
156
104
  }
157
105
  }
158
106
 
107
+ // Reconcile: boolean-only, and ALWAYS creates a NEW reconciliation txn (no updates).
159
108
  if (!target.reconcile) {
160
109
  continue;
161
110
  }
@@ -184,46 +133,26 @@ export async function scrapeAndImportTransactions({companyId, bank}: ScrapeTrans
184
133
  const currentBalance = actual.utils.integerToAmount(await actual.getAccountBalance(target.actualAccountId));
185
134
  const balanceDiff = scraperBalance - currentBalance;
186
135
 
187
- // Stable imported_id per Actual account so we update the same reconciliation txn each run.
188
- const reconciliationImportedId = `reconciliation-${target.actualAccountId}`;
189
-
190
- const allAccountTxns: TransactionEntity[] = await actual.getTransactions(
191
- target.actualAccountId,
192
- '2000-01-01',
193
- moment().add(1, 'year').format('YYYY-MM-DD'),
194
- );
195
-
196
- const existingReconciliation = allAccountTxns.find(txn => txn.imported_id === reconciliationImportedId);
197
-
198
- if (existingReconciliation && balanceDiff === 0) {
136
+ // If there is no diff, creating a reconciliation txn is typically noise.
137
+ // If you truly want a txn even for 0, remove this guard.
138
+ if (balanceDiff === 0) {
199
139
  log('RECONCILIATION_NOT_NEEDED', {actualAccountId: target.actualAccountId});
200
140
  continue;
201
141
  }
202
142
 
143
+ const targetKey = reconciliationTargetKey(target.accounts, selectedAccounts);
144
+ const reconciliationImportedId = uniqueReconciliationImportedId(target.actualAccountId);
145
+
203
146
  const reconciliationTxn = stripUndefined({
204
147
  account: target.actualAccountId,
205
148
  date: moment().format('YYYY-MM-DD'),
206
149
  amount: actual.utils.amountToInteger(balanceDiff),
207
150
  payee: null, // IMPORTANT: never pass undefined to updateTransaction schema
208
151
  imported_payee: 'Reconciliation',
209
- notes: `Reconciliation from ${currentBalance.toLocaleString()} to ${scraperBalance.toLocaleString()}`,
210
- imported_id: reconciliationImportedId,
152
+ notes: `Reconciliation (${targetKey}) from ${currentBalance.toLocaleString()} to ${scraperBalance.toLocaleString()}`,
153
+ imported_id: reconciliationImportedId, // NEW every run
211
154
  });
212
155
 
213
- if (existingReconciliation) {
214
- stdout.mute();
215
- await actual.updateTransaction(existingReconciliation.id, reconciliationTxn);
216
- stdout.unmute();
217
-
218
- log('RECONCILIATION_UPDATED', {
219
- actualAccountId: target.actualAccountId,
220
- from: currentBalance,
221
- to: scraperBalance,
222
- diff: balanceDiff,
223
- });
224
- continue;
225
- }
226
-
227
156
  stdout.mute();
228
157
  const reconciliationResult = await actual.importTransactions(target.actualAccountId, [reconciliationTxn]);
229
158
  stdout.unmute();
@@ -236,6 +165,7 @@ export async function scrapeAndImportTransactions({companyId, bank}: ScrapeTrans
236
165
  from: currentBalance,
237
166
  to: scraperBalance,
238
167
  diff: balanceDiff,
168
+ importedId: reconciliationImportedId,
239
169
  });
240
170
  }
241
171
  }
@@ -0,0 +1,98 @@
1
+ /* eslint-disable @typescript-eslint/no-unsafe-argument */
2
+ /* eslint-disable @typescript-eslint/no-unsafe-assignment */
3
+ /* eslint-disable @typescript-eslint/no-unsafe-call */
4
+ /* eslint-disable @typescript-eslint/no-unsafe-return */
5
+
6
+ import moment from 'moment';
7
+ import type {ConfigBankTarget, AccountsSelector} from './config';
8
+
9
+ export function isFiniteNumber(x: unknown): x is number {
10
+ return typeof x === 'number' && Number.isFinite(x);
11
+ }
12
+
13
+ export function stripUndefined<T extends Record<string, any>>(object: T): T {
14
+ return Object.fromEntries(Object.entries(object).filter(([, v]) => v !== undefined)) as T;
15
+ }
16
+
17
+ /**
18
+ * Normalizes bank config into a list of targets.
19
+ *
20
+ * IMPORTANT:
21
+ * - reconcile is boolean-only.
22
+ * - No consolidation modes.
23
+ */
24
+ export function normalizeTargets(bank: any): ConfigBankTarget[] {
25
+ // New config: targets[]
26
+ if (Array.isArray(bank?.targets) && bank.targets.length > 0) {
27
+ return bank.targets
28
+ .filter((t: any) => t?.actualAccountId)
29
+ .map((t: any) => ({
30
+ actualAccountId: t.actualAccountId,
31
+ reconcile: Boolean(t.reconcile),
32
+ accounts: t.accounts as AccountsSelector | undefined,
33
+ }));
34
+ }
35
+
36
+ // Legacy config: actualAccountId + reconcile
37
+ if (bank?.actualAccountId) {
38
+ return [{
39
+ actualAccountId: bank.actualAccountId,
40
+ reconcile: Boolean(bank.reconcile),
41
+ accounts: 'all',
42
+ }];
43
+ }
44
+
45
+ return [];
46
+ }
47
+
48
+ export function selectScraperAccounts(
49
+ allAccounts: any[] | undefined,
50
+ selector: AccountsSelector | undefined,
51
+ ) {
52
+ const accounts = allAccounts ?? [];
53
+ if (selector === undefined || selector === 'all') {
54
+ return accounts;
55
+ }
56
+
57
+ const set = new Set(selector);
58
+ return accounts.filter(a => set.has(String(a.accountNumber)));
59
+ }
60
+
61
+ export function reconciliationTargetKey(selector: AccountsSelector | undefined, selectedAccounts: any[]) {
62
+ // Prefer concrete selected account numbers (deterministic once scrape ran)
63
+ const nums = selectedAccounts
64
+ .map(a => String(a?.accountNumber))
65
+ .filter(Boolean)
66
+ .sort();
67
+
68
+ if (nums.length > 0) {
69
+ return nums.join(',');
70
+ }
71
+
72
+ // Fallback
73
+ if (selector === undefined || selector === 'all') {
74
+ return 'all';
75
+ }
76
+
77
+ return [...selector].map(String).sort().join(',');
78
+ }
79
+
80
+ export function stableImportedId(companyId: string, accountNumber: string | undefined, txn: any) {
81
+ // Prefer scraper identifier if present; fall back to a deterministic composite.
82
+ const idPart = txn?.identifier
83
+ ?? `${moment(txn?.date).format('YYYY-MM-DD')}:${txn?.chargedAmount}:${txn?.description ?? ''}:${txn?.memo ?? ''}`;
84
+
85
+ // AccountNumber is important once multiple cards are aggregated into one Actual account.
86
+ return `${companyId}:${accountNumber ?? 'unknown'}:${idPart}`;
87
+ }
88
+
89
+ /**
90
+ * Generates a unique reconciliation imported_id so a NEW txn is created every run.
91
+ * (No update logic; no consolidation logic.)
92
+ */
93
+ export function uniqueReconciliationImportedId(actualAccountId: string) {
94
+ // Date-based + randomness to avoid collisions if multiple targets reconcile in the same second.
95
+ const ts = moment().format('YYYY-MM-DDTHH:mm:ss.SSS');
96
+ const rand = Math.random().toString(16).slice(2, 10);
97
+ return `reconciliation-${actualAccountId}:${ts}:${rand}`;
98
+ }
package/src/index.ts CHANGED
@@ -14,7 +14,7 @@ import cronstrue from 'cronstrue';
14
14
  import stdout from 'mute-stdout';
15
15
  import config from '../config.json' assert {type: 'json'};
16
16
  import type {ConfigBank} from './config.d.ts';
17
- import {scrapeAndImportTransactions} from './utils.ts';
17
+ import {scrapeAndImportTransactions} from './importer';
18
18
 
19
19
  let scheduledTask: ScheduledTask;
20
20
 
File without changes