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 +14 -0
- package/README.md +39 -21
- package/config.schema.json +262 -47
- package/package.json +3 -3
- package/src/config.d.ts +8 -3
- package/src/{utils.ts → importer.ts} +25 -95
- package/src/importer.utils.ts +98 -0
- package/src/index.ts +1 -1
- /package/src/{utils.d.ts → importer.d.ts} +0 -0
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
|
|
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**
|
|
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
|
|
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
|
|
21
|
+
4. **Reconciliation**
|
|
22
|
+
Optional reconciliation to adjust account balances automatically.
|
|
19
23
|
|
|
20
|
-
5. **Credit Card / Multi-Account Mapping (Targets)
|
|
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
|
|
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
|
|
52
|
-
2. `banks
|
|
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`
|
|
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`
|
|
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.)
|
|
96
|
+
(e.g. `userCode`, `username`, `password`, etc.).
|
|
97
|
+
|
|
98
|
+
---
|
|
90
99
|
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/config.schema.json
CHANGED
|
@@ -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
|
-
"
|
|
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
|
-
"
|
|
139
|
+
"$ref": "#/definitions/ReconcileOption"
|
|
66
140
|
},
|
|
67
|
-
"
|
|
68
|
-
"type": "
|
|
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
|
-
"
|
|
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
|
-
"
|
|
180
|
+
"$ref": "#/definitions/ReconcileOption"
|
|
89
181
|
},
|
|
90
|
-
"
|
|
91
|
-
"type": "
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
297
|
+
"id": {
|
|
155
298
|
"type": "string"
|
|
156
299
|
},
|
|
157
300
|
"card6Digits": {
|
|
158
301
|
"type": "string"
|
|
159
302
|
},
|
|
160
|
-
"
|
|
303
|
+
"password": {
|
|
161
304
|
"type": "string"
|
|
162
305
|
},
|
|
163
|
-
"
|
|
306
|
+
"actualAccountId": {
|
|
164
307
|
"type": "string"
|
|
165
308
|
},
|
|
166
309
|
"reconcile": {
|
|
167
|
-
"
|
|
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
|
-
"
|
|
342
|
+
"nationalID": {
|
|
182
343
|
"type": "string"
|
|
183
344
|
},
|
|
184
|
-
"
|
|
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
|
-
"
|
|
355
|
+
"$ref": "#/definitions/ReconcileOption"
|
|
192
356
|
},
|
|
193
|
-
"
|
|
194
|
-
"type": "
|
|
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
|
-
"
|
|
201
|
-
"
|
|
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
|
-
"
|
|
215
|
-
"
|
|
390
|
+
"phoneNumber": {
|
|
391
|
+
"type": "string"
|
|
216
392
|
},
|
|
217
393
|
"password": {
|
|
218
394
|
"type": "string"
|
|
219
395
|
},
|
|
220
|
-
"
|
|
396
|
+
"otpCodeRetriever": {
|
|
397
|
+
"$comment": "() => Promise<string>"
|
|
398
|
+
},
|
|
399
|
+
"actualAccountId": {
|
|
221
400
|
"type": "string"
|
|
222
401
|
},
|
|
223
402
|
"reconcile": {
|
|
224
|
-
"
|
|
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
|
-
"
|
|
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.
|
|
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": "^
|
|
29
|
-
"@tomerh2001/israeli-bank-scrapers": "
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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
|
|
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 './
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
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
|
-
//
|
|
188
|
-
|
|
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 './
|
|
17
|
+
import {scrapeAndImportTransactions} from './importer';
|
|
18
18
|
|
|
19
19
|
let scheduledTask: ScheduledTask;
|
|
20
20
|
|
|
File without changes
|