israeli-banks-actual-budget-importer 1.9.0 → 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 +7 -0
- package/README.md +39 -21
- package/config.schema.json +20 -10
- package/package.json +2 -2
- 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,10 @@
|
|
|
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
|
+
|
|
1
8
|
# [1.9.0](https://github.com/tomerh2001/israeli-banks-actual-budget-importer/compare/v1.8.1...v1.9.0) (2026-01-03)
|
|
2
9
|
|
|
3
10
|
|
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
|
@@ -64,6 +64,16 @@
|
|
|
64
64
|
}
|
|
65
65
|
]
|
|
66
66
|
},
|
|
67
|
+
"ReconcileOption": {
|
|
68
|
+
"anyOf": [
|
|
69
|
+
{
|
|
70
|
+
"type": "boolean"
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"const": "consolidate"
|
|
74
|
+
}
|
|
75
|
+
]
|
|
76
|
+
},
|
|
67
77
|
"ConfigBankTarget": {
|
|
68
78
|
"additionalProperties": false,
|
|
69
79
|
"properties": {
|
|
@@ -71,7 +81,7 @@
|
|
|
71
81
|
"type": "string"
|
|
72
82
|
},
|
|
73
83
|
"reconcile": {
|
|
74
|
-
"
|
|
84
|
+
"$ref": "#/definitions/ReconcileOption"
|
|
75
85
|
},
|
|
76
86
|
"accounts": {
|
|
77
87
|
"$ref": "#/definitions/AccountsSelector"
|
|
@@ -89,7 +99,7 @@
|
|
|
89
99
|
"type": "string"
|
|
90
100
|
},
|
|
91
101
|
"reconcile": {
|
|
92
|
-
"
|
|
102
|
+
"$ref": "#/definitions/ReconcileOption"
|
|
93
103
|
}
|
|
94
104
|
},
|
|
95
105
|
"required": [
|
|
@@ -126,7 +136,7 @@
|
|
|
126
136
|
"type": "string"
|
|
127
137
|
},
|
|
128
138
|
"reconcile": {
|
|
129
|
-
"
|
|
139
|
+
"$ref": "#/definitions/ReconcileOption"
|
|
130
140
|
},
|
|
131
141
|
"targets": {
|
|
132
142
|
"type": "array",
|
|
@@ -167,7 +177,7 @@
|
|
|
167
177
|
"type": "string"
|
|
168
178
|
},
|
|
169
179
|
"reconcile": {
|
|
170
|
-
"
|
|
180
|
+
"$ref": "#/definitions/ReconcileOption"
|
|
171
181
|
},
|
|
172
182
|
"targets": {
|
|
173
183
|
"type": "array",
|
|
@@ -208,7 +218,7 @@
|
|
|
208
218
|
"type": "string"
|
|
209
219
|
},
|
|
210
220
|
"reconcile": {
|
|
211
|
-
"
|
|
221
|
+
"$ref": "#/definitions/ReconcileOption"
|
|
212
222
|
},
|
|
213
223
|
"targets": {
|
|
214
224
|
"type": "array",
|
|
@@ -252,7 +262,7 @@
|
|
|
252
262
|
"type": "string"
|
|
253
263
|
},
|
|
254
264
|
"reconcile": {
|
|
255
|
-
"
|
|
265
|
+
"$ref": "#/definitions/ReconcileOption"
|
|
256
266
|
},
|
|
257
267
|
"targets": {
|
|
258
268
|
"type": "array",
|
|
@@ -297,7 +307,7 @@
|
|
|
297
307
|
"type": "string"
|
|
298
308
|
},
|
|
299
309
|
"reconcile": {
|
|
300
|
-
"
|
|
310
|
+
"$ref": "#/definitions/ReconcileOption"
|
|
301
311
|
},
|
|
302
312
|
"targets": {
|
|
303
313
|
"type": "array",
|
|
@@ -342,7 +352,7 @@
|
|
|
342
352
|
"type": "string"
|
|
343
353
|
},
|
|
344
354
|
"reconcile": {
|
|
345
|
-
"
|
|
355
|
+
"$ref": "#/definitions/ReconcileOption"
|
|
346
356
|
},
|
|
347
357
|
"targets": {
|
|
348
358
|
"type": "array",
|
|
@@ -390,7 +400,7 @@
|
|
|
390
400
|
"type": "string"
|
|
391
401
|
},
|
|
392
402
|
"reconcile": {
|
|
393
|
-
"
|
|
403
|
+
"$ref": "#/definitions/ReconcileOption"
|
|
394
404
|
},
|
|
395
405
|
"targets": {
|
|
396
406
|
"type": "array",
|
|
@@ -436,7 +446,7 @@
|
|
|
436
446
|
"type": "string"
|
|
437
447
|
},
|
|
438
448
|
"reconcile": {
|
|
439
|
-
"
|
|
449
|
+
"$ref": "#/definitions/ReconcileOption"
|
|
440
450
|
},
|
|
441
451
|
"targets": {
|
|
442
452
|
"type": "array",
|
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,7 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"packageManager": "yarn@4.12.0",
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@actual-app/api": "^
|
|
28
|
+
"@actual-app/api": "^26.1.0",
|
|
29
29
|
"@tomerh2001/israeli-bank-scrapers": "6.4.0",
|
|
30
30
|
"cronstrue": "^3.9.0",
|
|
31
31
|
"lodash": "^4.17.21",
|
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
|