israeli-banks-actual-budget-importer 1.7.5 → 1.8.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 +177 -16
- package/package.json +5 -5
- package/src/config.d.ts +42 -1
- package/src/index.ts +1 -1
- package/src/utils.d.ts +3 -4
- package/src/utils.ts +188 -77
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [1.8.0](https://github.com/tomerh2001/israeli-banks-actual-budget-importer/compare/v1.7.6...v1.8.0) (2026-01-03)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **config:** add targets/accounts mapping and update reconciliation docs ([9f56fe0](https://github.com/tomerh2001/israeli-banks-actual-budget-importer/commit/9f56fe0fa984304186af4c95175a2e3ceb6b83b8))
|
|
7
|
+
|
|
8
|
+
## [1.7.6](https://github.com/tomerh2001/israeli-banks-actual-budget-importer/compare/v1.7.5...v1.7.6) (2025-12-04)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* update transaction scraping start date to two years ago for improved data accuracy ([a08e3e6](https://github.com/tomerh2001/israeli-banks-actual-budget-importer/commit/a08e3e699650844ed1015879355bdd9d157e2c60))
|
|
14
|
+
|
|
1
15
|
## [1.7.5](https://github.com/tomerh2001/israeli-banks-actual-budget-importer/compare/v1.7.4...v1.7.5) (2025-12-04)
|
|
2
16
|
|
|
3
17
|
|
package/README.md
CHANGED
|
@@ -17,7 +17,9 @@ This project provides an importer from Israeli banks (via [israeli-bank-scrapers
|
|
|
17
17
|
|
|
18
18
|
4. **Reconciliation:** Optional reconciliation to adjust account balances automatically.
|
|
19
19
|
|
|
20
|
-
5. **
|
|
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`).
|
|
21
|
+
|
|
22
|
+
6. **Concurrent Processing:** Uses a queue (via [p-queue](https://www.npmjs.com/package/p-queue)) to manage scraping tasks concurrently.
|
|
21
23
|
|
|
22
24
|
## Installation
|
|
23
25
|
|
|
@@ -42,25 +44,176 @@ services:
|
|
|
42
44
|
|
|
43
45
|
## Configuration
|
|
44
46
|
|
|
45
|
-
The application configuration is defined using JSON and validated against a schema.
|
|
47
|
+
The application configuration is defined using JSON and validated against a schema.
|
|
48
|
+
The main configuration file is `config.json`.
|
|
49
|
+
|
|
50
|
+
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.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
### 1) `actual` configuration
|
|
57
|
+
|
|
58
|
+
This section configures the connection to your Actual Budget server and budget.
|
|
59
|
+
It is **always required**, regardless of how you configure banks or targets.
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"actual": {
|
|
64
|
+
"init": {
|
|
65
|
+
"dataDir": "./data",
|
|
66
|
+
"password": "your_actual_password",
|
|
67
|
+
"serverURL": "https://your-actual-server.com"
|
|
68
|
+
},
|
|
69
|
+
"budget": {
|
|
70
|
+
"syncId": "your_sync_id",
|
|
71
|
+
"password": "your_budget_password"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Nothing in this block changes when using `targets`, credit cards, or multi-account mappings.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
### 2) `banks` configuration
|
|
82
|
+
|
|
83
|
+
The `banks` section defines:
|
|
84
|
+
- Which banks to scrape
|
|
85
|
+
- The credentials for each bank
|
|
86
|
+
- How scraped accounts/cards are mapped into Actual accounts
|
|
87
|
+
|
|
88
|
+
Each bank entry includes the credentials required by `israeli-bank-scrapers`
|
|
89
|
+
(e.g. `userCode`, `username`, `password`, etc.) and supports **multiple mapping modes**.
|
|
46
90
|
|
|
47
|
-
|
|
91
|
+
#### Using `targets` (recommended)
|
|
48
92
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
- `init`: Initialization parameters (e.g., server URL, password).
|
|
52
|
-
- `budget`: Contains properties like `syncId` and `password` for synchronizing budgets.
|
|
93
|
+
A single bank scrape (for example `visaCal`) may return **multiple accounts/cards**.
|
|
94
|
+
Different users model these differently in Actual, so the importer supports `targets`.
|
|
53
95
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
- `password`: The bank account password.
|
|
58
|
-
- Additional properties (e.g., `userCode`, `username`, or other bank-specific credentials) as required.
|
|
59
|
-
- `reconcile` (optional): A flag to enable balance reconciliation.
|
|
96
|
+
Each **target** represents:
|
|
97
|
+
- One Actual account
|
|
98
|
+
- One or more scraped accounts/cards that feed into it
|
|
60
99
|
|
|
61
|
-
|
|
100
|
+
For each target:
|
|
101
|
+
- Imported transactions = concatenation of transactions from selected cards
|
|
102
|
+
- Reconciliation (if enabled) = sum of balances of selected cards
|
|
103
|
+
(only cards with a valid numeric balance are included)
|
|
62
104
|
|
|
63
|
-
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
#### Example A: One Actual account for all VisaCal cards (consolidated)
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{
|
|
111
|
+
"actual": {
|
|
112
|
+
"init": {
|
|
113
|
+
"dataDir": "./data",
|
|
114
|
+
"password": "your_actual_password",
|
|
115
|
+
"serverURL": "https://your-actual-server.com"
|
|
116
|
+
},
|
|
117
|
+
"budget": {
|
|
118
|
+
"syncId": "your_sync_id",
|
|
119
|
+
"password": "your_budget_password"
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
"banks": {
|
|
123
|
+
"visaCal": {
|
|
124
|
+
"username": "bank_username",
|
|
125
|
+
"password": "bank_password",
|
|
126
|
+
"targets": [
|
|
127
|
+
{
|
|
128
|
+
"actualAccountId": "actual-creditcards-all",
|
|
129
|
+
"reconcile": true,
|
|
130
|
+
"accounts": "all"
|
|
131
|
+
}
|
|
132
|
+
]
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
#### Example B: One Actual account per VisaCal card (separate accounts)
|
|
141
|
+
|
|
142
|
+
```json
|
|
143
|
+
{
|
|
144
|
+
"actual": {
|
|
145
|
+
"init": {
|
|
146
|
+
"dataDir": "./data",
|
|
147
|
+
"password": "your_actual_password",
|
|
148
|
+
"serverURL": "https://your-actual-server.com"
|
|
149
|
+
},
|
|
150
|
+
"budget": {
|
|
151
|
+
"syncId": "your_sync_id",
|
|
152
|
+
"password": "your_budget_password"
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
"banks": {
|
|
156
|
+
"visaCal": {
|
|
157
|
+
"username": "bank_username",
|
|
158
|
+
"password": "bank_password",
|
|
159
|
+
"targets": [
|
|
160
|
+
{
|
|
161
|
+
"actualAccountId": "actual-card-8538",
|
|
162
|
+
"reconcile": true,
|
|
163
|
+
"accounts": ["8538"]
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
"actualAccountId": "actual-card-7697",
|
|
167
|
+
"reconcile": true,
|
|
168
|
+
"accounts": ["7697"]
|
|
169
|
+
}
|
|
170
|
+
]
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
#### Example C: Grouped cards into a single Actual account (subset)
|
|
179
|
+
|
|
180
|
+
```json
|
|
181
|
+
{
|
|
182
|
+
"actual": {
|
|
183
|
+
"init": {
|
|
184
|
+
"dataDir": "./data",
|
|
185
|
+
"password": "your_actual_password",
|
|
186
|
+
"serverURL": "https://your-actual-server.com"
|
|
187
|
+
},
|
|
188
|
+
"budget": {
|
|
189
|
+
"syncId": "your_sync_id",
|
|
190
|
+
"password": "your_budget_password"
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
"banks": {
|
|
194
|
+
"visaCal": {
|
|
195
|
+
"username": "bank_username",
|
|
196
|
+
"password": "bank_password",
|
|
197
|
+
"targets": [
|
|
198
|
+
{
|
|
199
|
+
"actualAccountId": "actual-cal-primary",
|
|
200
|
+
"reconcile": true,
|
|
201
|
+
"accounts": ["8538", "7697"]
|
|
202
|
+
}
|
|
203
|
+
]
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
## Legacy configuration (single Actual account per bank)
|
|
212
|
+
|
|
213
|
+
This configuration style is **fully supported for backward compatibility**,
|
|
214
|
+
but does **not** allow fine-grained control over multiple cards/accounts.
|
|
215
|
+
|
|
216
|
+
It maps all scraped accounts from the bank into a single Actual account.
|
|
64
217
|
|
|
65
218
|
```json
|
|
66
219
|
{
|
|
@@ -87,11 +240,19 @@ Example snippet:
|
|
|
87
240
|
"username": "bank_username",
|
|
88
241
|
"password": "bank_password"
|
|
89
242
|
}
|
|
90
|
-
// Additional bank configurations go here...
|
|
91
243
|
}
|
|
92
244
|
}
|
|
93
245
|
```
|
|
94
246
|
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## Notes
|
|
250
|
+
|
|
251
|
+
- The `actual` block is **always required** and independent of bank configuration.
|
|
252
|
+
- `targets` are optional but strongly recommended for credit-card providers.
|
|
253
|
+
- Duplicate transactions are prevented using a stable `imported_id`.
|
|
254
|
+
- Credit card balances are often negative; reconciliation uses the values as returned by the bank.
|
|
255
|
+
|
|
95
256
|
## License
|
|
96
257
|
|
|
97
258
|
This project is open-source. Please see the [LICENSE](./LICENSE) file for licensing details.
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.
|
|
2
|
+
"version": "1.8.0",
|
|
3
3
|
"name": "israeli-banks-actual-budget-importer",
|
|
4
4
|
"module": "index.ts",
|
|
5
5
|
"type": "module",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"@semantic-release/npm": "^13.1.2",
|
|
17
17
|
"@semantic-release/release-notes-generator": "^14.1.0",
|
|
18
18
|
"@types/lodash": "^4.17.21",
|
|
19
|
-
"@types/papaparse": "^5.5.
|
|
19
|
+
"@types/papaparse": "^5.5.2",
|
|
20
20
|
"bun-types": "latest",
|
|
21
21
|
"papaparse": "^5.5.3",
|
|
22
22
|
"semantic-release": "^25.0.2",
|
|
@@ -25,14 +25,14 @@
|
|
|
25
25
|
},
|
|
26
26
|
"packageManager": "yarn@4.12.0",
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@actual-app/api": "^25.
|
|
28
|
+
"@actual-app/api": "^25.12.0",
|
|
29
|
+
"@tomerh2001/israeli-bank-scrapers": "latest",
|
|
29
30
|
"cronstrue": "^3.9.0",
|
|
30
|
-
"israeli-bank-scrapers": "^6.2.5",
|
|
31
31
|
"lodash": "^4.17.21",
|
|
32
32
|
"moment": "^2.30.1",
|
|
33
33
|
"mute-stdout": "^2.0.0",
|
|
34
34
|
"node-cron": "^4.2.1",
|
|
35
35
|
"p-queue": "^9.0.1",
|
|
36
|
-
"tsx": "^4.
|
|
36
|
+
"tsx": "^4.21.0"
|
|
37
37
|
}
|
|
38
38
|
}
|
package/src/config.d.ts
CHANGED
|
@@ -18,7 +18,48 @@ export type ConfigActualBudget = {
|
|
|
18
18
|
|
|
19
19
|
export type ConfigBanks = Partial<Record<CompanyTypes, ConfigBank>>;
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
/**
|
|
22
|
+
* A single "import target" inside Actual.
|
|
23
|
+
* One target maps one Actual account to one or more scraped accounts/cards.
|
|
24
|
+
*/
|
|
25
|
+
export type ConfigBankTarget = {
|
|
26
|
+
/**
|
|
27
|
+
* Actual Budget account ID to import into and (optionally) reconcile against.
|
|
28
|
+
*/
|
|
22
29
|
actualAccountId: string;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* If true, create/update a reconciliation transaction to match the scraped balance.
|
|
33
|
+
*/
|
|
34
|
+
reconcile?: boolean;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Which scraped accounts (by accountNumber) should be included in this target.
|
|
38
|
+
* - "all": include all scraped accounts with usable data (final selection logic lives in code).
|
|
39
|
+
* - string[]: include only those accountNumbers.
|
|
40
|
+
*
|
|
41
|
+
* If omitted, default behavior should match legacy behavior:
|
|
42
|
+
* - treat as "all" for import, and for reconciliation use the first usable balance
|
|
43
|
+
* (you'll refine this in the implementation files).
|
|
44
|
+
*/
|
|
45
|
+
accounts?: 'all' | string[];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Bank config remains compatible with existing configs:
|
|
50
|
+
* - Legacy: actualAccountId + reconcile at top level
|
|
51
|
+
* - New: targets[]
|
|
52
|
+
*/
|
|
53
|
+
export type ConfigBank = ScraperCredentials & {
|
|
54
|
+
/**
|
|
55
|
+
* New preferred configuration: one bank can have multiple import targets.
|
|
56
|
+
*/
|
|
57
|
+
targets?: ConfigBankTarget[];
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Legacy single-target fields (backward compatible).
|
|
61
|
+
* If targets is provided, these should be ignored by runtime logic.
|
|
62
|
+
*/
|
|
63
|
+
actualAccountId?: string;
|
|
23
64
|
reconcile?: boolean;
|
|
24
65
|
};
|
package/src/index.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
/* eslint-disable no-await-in-loop */
|
|
5
5
|
|
|
6
6
|
import process from 'node:process';
|
|
7
|
-
import {type CompanyTypes} from 'israeli-bank-scrapers';
|
|
7
|
+
import {type CompanyTypes} from '@tomerh2001/israeli-bank-scrapers';
|
|
8
8
|
import _ from 'lodash';
|
|
9
9
|
import actual from '@actual-app/api';
|
|
10
10
|
import Queue from 'p-queue';
|
package/src/utils.d.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type
|
|
3
|
-
import {type ConfigBank} from '../config.js';
|
|
1
|
+
import type {CompanyTypes} from 'israeli-bank-scrapers';
|
|
2
|
+
import type {ConfigBank} from '../config.js';
|
|
4
3
|
|
|
5
4
|
export type ScrapeTransactionsContext = {
|
|
6
5
|
companyId: CompanyTypes;
|
|
7
6
|
bank: ConfigBank;
|
|
8
|
-
};
|
|
7
|
+
};
|
package/src/utils.ts
CHANGED
|
@@ -4,29 +4,99 @@
|
|
|
4
4
|
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
|
5
5
|
|
|
6
6
|
import process from 'node:process';
|
|
7
|
-
import {createScraper, type ScraperCredentials} from 'israeli-bank-scrapers';
|
|
7
|
+
import {createScraper, type ScraperCredentials} from '@tomerh2001/israeli-bank-scrapers';
|
|
8
8
|
import _ from 'lodash';
|
|
9
9
|
import moment from 'moment';
|
|
10
10
|
import actual from '@actual-app/api';
|
|
11
|
-
import {type PayeeEntity, type TransactionEntity} from '@actual-app/api/@types/loot-core/types/models';
|
|
11
|
+
import {type PayeeEntity, type TransactionEntity} from '@actual-app/api/@types/loot-core/src/types/models';
|
|
12
12
|
import stdout from 'mute-stdout';
|
|
13
13
|
import {type ScrapeTransactionsContext} from './utils.d';
|
|
14
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
|
+
}
|
|
78
|
+
|
|
15
79
|
export async function scrapeAndImportTransactions({companyId, bank}: ScrapeTransactionsContext) {
|
|
16
80
|
function log(status: any, other?: Record<string, unknown>) {
|
|
17
81
|
console.debug({bank: companyId, status, ...other});
|
|
18
82
|
}
|
|
19
83
|
|
|
20
84
|
try {
|
|
85
|
+
const targets = normalizeTargets(bank);
|
|
86
|
+
if (targets.length === 0) {
|
|
87
|
+
throw new Error(`No targets configured for ${companyId}. Provide bank.actualAccountId (legacy) or bank.targets[].actualAccountId.`);
|
|
88
|
+
}
|
|
89
|
+
|
|
21
90
|
const scraper = createScraper({
|
|
22
91
|
companyId,
|
|
23
|
-
startDate: moment().subtract(
|
|
92
|
+
startDate: moment().subtract(2, 'years').toDate(),
|
|
24
93
|
// ExecutablePath: '/opt/homebrew/bin/chromium',
|
|
25
94
|
args: ['--user-data-dir=./chrome-data'],
|
|
26
95
|
additionalTransactionInformation: true,
|
|
27
96
|
verbose: process.env?.VERBOSE === 'true',
|
|
28
97
|
showBrowser: process.env?.SHOW_BROWSER === 'true',
|
|
29
98
|
});
|
|
99
|
+
|
|
30
100
|
scraper.onProgress((_companyId, payload) => {
|
|
31
101
|
log(payload.type);
|
|
32
102
|
});
|
|
@@ -36,95 +106,136 @@ export async function scrapeAndImportTransactions({companyId, bank}: ScrapeTrans
|
|
|
36
106
|
throw new Error(`Failed to scrape (${result.errorType}): ${result.errorMessage}`);
|
|
37
107
|
}
|
|
38
108
|
|
|
39
|
-
|
|
40
|
-
.
|
|
41
|
-
|
|
42
|
-
|
|
109
|
+
log('ACCOUNTS', {
|
|
110
|
+
accounts: result.accounts?.map(x => ({
|
|
111
|
+
accountNumber: x.accountNumber,
|
|
112
|
+
balance: x.balance,
|
|
113
|
+
txns: x.txns.length,
|
|
114
|
+
})),
|
|
115
|
+
});
|
|
43
116
|
|
|
44
|
-
|
|
45
|
-
|
|
117
|
+
const payees: PayeeEntity[] = await actual.getPayees();
|
|
118
|
+
|
|
119
|
+
// Process each target independently (supports per-card, per-company, and consolidated).
|
|
120
|
+
for (const target of targets) {
|
|
121
|
+
const selectedAccounts = selectScraperAccounts(result.accounts as any[], target.accounts);
|
|
122
|
+
|
|
123
|
+
// Transactions to import: selected accounts with txns.
|
|
124
|
+
const transactions = _(selectedAccounts)
|
|
125
|
+
.filter(a => Array.isArray(a.txns) && a.txns.length > 0)
|
|
126
|
+
.flatMap(a => a.txns.map((t: any) => ({txn: t, accountNumber: String(a.accountNumber)})))
|
|
127
|
+
.value();
|
|
128
|
+
|
|
129
|
+
if (transactions.length === 0) {
|
|
130
|
+
log('NO_TRANSACTIONS', {actualAccountId: target.actualAccountId});
|
|
131
|
+
} else {
|
|
132
|
+
const mappedTransactions = transactions.map(async ({txn, accountNumber}) => stripUndefined({
|
|
133
|
+
date: moment(txn.date).format('YYYY-MM-DD'),
|
|
134
|
+
amount: actual.utils.amountToInteger(txn.chargedAmount),
|
|
135
|
+
payee: _.find(payees, {name: txn.description})?.id ?? (await actual.createPayee({name: txn.description})),
|
|
136
|
+
imported_payee: txn.description,
|
|
137
|
+
notes: txn.memo,
|
|
138
|
+
imported_id: stableImportedId(companyId, accountNumber, txn),
|
|
139
|
+
}));
|
|
140
|
+
|
|
141
|
+
stdout.mute();
|
|
142
|
+
const importResult = await actual.importTransactions(
|
|
143
|
+
target.actualAccountId,
|
|
144
|
+
await Promise.all(mappedTransactions),
|
|
145
|
+
{defaultCleared: true},
|
|
146
|
+
);
|
|
147
|
+
stdout.unmute();
|
|
148
|
+
|
|
149
|
+
if (_.isEmpty(importResult)) {
|
|
150
|
+
console.error('Errors', importResult.errors);
|
|
151
|
+
throw new Error('Failed to import transactions');
|
|
152
|
+
} else {
|
|
153
|
+
log('IMPORTED', {actualAccountId: target.actualAccountId, transactions: importResult.added.length});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!target.reconcile) {
|
|
46
158
|
continue;
|
|
47
159
|
}
|
|
48
|
-
}
|
|
49
160
|
|
|
50
|
-
|
|
51
|
-
|
|
161
|
+
// Reconciliation balance: sum finite balances of selected accounts.
|
|
162
|
+
const reconAccounts = selectedAccounts
|
|
163
|
+
.filter(a => isFiniteNumber(a?.balance))
|
|
164
|
+
.map(a => ({accountNumber: String(a.accountNumber), balance: a.balance as number}));
|
|
52
165
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}));
|
|
61
|
-
|
|
62
|
-
stdout.mute();
|
|
63
|
-
const importResult = await actual.importTransactions(bank.actualAccountId, await Promise.all(mappedTransactions), {defaultCleared: true});
|
|
64
|
-
stdout.unmute();
|
|
65
|
-
|
|
66
|
-
if (_.isEmpty(importResult)) {
|
|
67
|
-
console.error('Errors', importResult.errors);
|
|
68
|
-
throw new Error('Failed to import transactions');
|
|
69
|
-
} else {
|
|
70
|
-
log('IMPORTED', {transactions: importResult.added.length});
|
|
71
|
-
}
|
|
166
|
+
if (reconAccounts.length === 0) {
|
|
167
|
+
log('RECONCILE_SKIPPED_NO_BALANCES', {
|
|
168
|
+
actualAccountId: target.actualAccountId,
|
|
169
|
+
selectedAccounts: selectedAccounts.map(a => a?.accountNumber),
|
|
170
|
+
});
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
72
173
|
|
|
73
|
-
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
174
|
+
const scraperBalance = reconAccounts.reduce((sum, a) => sum + a.balance, 0);
|
|
76
175
|
|
|
77
|
-
|
|
78
|
-
|
|
176
|
+
log('RECONCILE_INPUT', {
|
|
177
|
+
actualAccountId: target.actualAccountId,
|
|
178
|
+
accounts: reconAccounts,
|
|
179
|
+
balance: scraperBalance,
|
|
180
|
+
});
|
|
79
181
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const reconciliationImportedId = `reconciliation-${bank.actualAccountId}`;
|
|
182
|
+
const currentBalance = actual.utils.integerToAmount(await actual.getAccountBalance(target.actualAccountId));
|
|
183
|
+
const balanceDiff = scraperBalance - currentBalance;
|
|
83
184
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const allAccountTxns: TransactionEntity[] = await actual.getTransactions(
|
|
87
|
-
bank.actualAccountId,
|
|
88
|
-
'2000-01-01',
|
|
89
|
-
moment().add(1, 'year').format('YYYY-MM-DD'),
|
|
90
|
-
);
|
|
185
|
+
// Stable imported_id per Actual account so we update the same reconciliation txn each run.
|
|
186
|
+
const reconciliationImportedId = `reconciliation-${target.actualAccountId}`;
|
|
91
187
|
|
|
92
|
-
|
|
188
|
+
const allAccountTxns: TransactionEntity[] = await actual.getTransactions(
|
|
189
|
+
target.actualAccountId,
|
|
190
|
+
'2000-01-01',
|
|
191
|
+
moment().add(1, 'year').format('YYYY-MM-DD'),
|
|
192
|
+
);
|
|
93
193
|
|
|
94
|
-
|
|
95
|
-
if (existingReconciliation && balanceDiff === 0) {
|
|
96
|
-
log('RECONCILIATION_NOT_NEEDED');
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
194
|
+
const existingReconciliation = allAccountTxns.find(txn => txn.imported_id === reconciliationImportedId);
|
|
99
195
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
payee: undefined,
|
|
105
|
-
imported_payee: 'Reconciliation',
|
|
106
|
-
notes: `Reconciliation from ${currentBalance.toLocaleString()} to ${accountBalance.toLocaleString()}`,
|
|
107
|
-
imported_id: reconciliationImportedId,
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
if (existingReconciliation) {
|
|
111
|
-
stdout.mute();
|
|
112
|
-
await actual.updateTransaction(existingReconciliation.id, reconciliationTxn);
|
|
113
|
-
stdout.unmute();
|
|
196
|
+
if (existingReconciliation && balanceDiff === 0) {
|
|
197
|
+
log('RECONCILIATION_NOT_NEEDED', {actualAccountId: target.actualAccountId});
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
114
200
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
201
|
+
const reconciliationTxn = stripUndefined({
|
|
202
|
+
account: target.actualAccountId,
|
|
203
|
+
date: moment().format('YYYY-MM-DD'),
|
|
204
|
+
amount: actual.utils.amountToInteger(balanceDiff),
|
|
205
|
+
payee: null, // IMPORTANT: never pass undefined to updateTransaction schema
|
|
206
|
+
imported_payee: 'Reconciliation',
|
|
207
|
+
notes: `Reconciliation from ${currentBalance.toLocaleString()} to ${scraperBalance.toLocaleString()}`,
|
|
208
|
+
imported_id: reconciliationImportedId,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
if (existingReconciliation) {
|
|
212
|
+
stdout.mute();
|
|
213
|
+
await actual.updateTransaction(existingReconciliation.id, reconciliationTxn);
|
|
214
|
+
stdout.unmute();
|
|
215
|
+
|
|
216
|
+
log('RECONCILIATION_UPDATED', {
|
|
217
|
+
actualAccountId: target.actualAccountId,
|
|
218
|
+
from: currentBalance,
|
|
219
|
+
to: scraperBalance,
|
|
220
|
+
diff: balanceDiff,
|
|
221
|
+
});
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
118
224
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
stdout.unmute();
|
|
225
|
+
stdout.mute();
|
|
226
|
+
const reconciliationResult = await actual.importTransactions(target.actualAccountId, [reconciliationTxn]);
|
|
227
|
+
stdout.unmute();
|
|
123
228
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
229
|
+
if (!reconciliationResult || _.isEmpty(reconciliationResult.added)) {
|
|
230
|
+
console.error('Reconciliation errors', reconciliationResult?.errors);
|
|
231
|
+
} else {
|
|
232
|
+
log('RECONCILIATION_ADDED', {
|
|
233
|
+
actualAccountId: target.actualAccountId,
|
|
234
|
+
from: currentBalance,
|
|
235
|
+
to: scraperBalance,
|
|
236
|
+
diff: balanceDiff,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
128
239
|
}
|
|
129
240
|
} catch (error) {
|
|
130
241
|
console.error('Error', companyId, error);
|