israeli-banks-actual-budget-importer 1.7.6 → 1.8.1

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.8.1](https://github.com/tomerh2001/israeli-banks-actual-budget-importer/compare/v1.8.0...v1.8.1) (2026-01-03)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **logging:** add timestamp to debug logs in scrapeAndImportTransactions ([79a8f53](https://github.com/tomerh2001/israeli-banks-actual-budget-importer/commit/79a8f53726e63fd72234a1a4066ce9e151397fb1))
7
+
8
+ # [1.8.0](https://github.com/tomerh2001/israeli-banks-actual-budget-importer/compare/v1.7.6...v1.8.0) (2026-01-03)
9
+
10
+
11
+ ### Features
12
+
13
+ * **config:** add targets/accounts mapping and update reconciliation docs ([9f56fe0](https://github.com/tomerh2001/israeli-banks-actual-budget-importer/commit/9f56fe0fa984304186af4c95175a2e3ceb6b83b8))
14
+
1
15
  ## [1.7.6](https://github.com/tomerh2001/israeli-banks-actual-budget-importer/compare/v1.7.5...v1.7.6) (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. **Concurrent Processing:** Uses a queue (via [p-queue](https://www.npmjs.com/package/p-queue)) to manage scraping tasks concurrently.
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. The key configuration file is `config.json` and its schema is described in `config.schema.json`.
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
- ### Configuration Structure
91
+ #### Using `targets` (recommended)
48
92
 
49
- - **actual:**
50
- Contains settings for the Actual API integration:
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
- - **banks:**
55
- Defines bank-specific settings for each supported bank. Each entry typically requires:
56
- - `actualAccountId`: The account identifier in Actual.
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
- Make sure your `config.json` follows the schema defined in `config.schema.json`.
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
- Example snippet:
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.7.6",
2
+ "version": "1.8.1",
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.0",
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.11.0",
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.20.6"
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
- export type ConfigBank = ScraperCredentials & {
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 {ScraperCredentials, CompanyTypes} from 'israeli-bank-scrapers';
2
- import type actual from '@actual-app/api';
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,20 +4,91 @@
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
- console.debug({bank: companyId, status, ...other});
81
+ console.debug({
82
+ datetime: new Date().toISOString(), bank: companyId, status, ...other,
83
+ });
18
84
  }
19
85
 
20
86
  try {
87
+ const targets = normalizeTargets(bank);
88
+ if (targets.length === 0) {
89
+ throw new Error(`No targets configured for ${companyId}. Provide bank.actualAccountId (legacy) or bank.targets[].actualAccountId.`);
90
+ }
91
+
21
92
  const scraper = createScraper({
22
93
  companyId,
23
94
  startDate: moment().subtract(2, 'years').toDate(),
@@ -27,6 +98,7 @@ export async function scrapeAndImportTransactions({companyId, bank}: ScrapeTrans
27
98
  verbose: process.env?.VERBOSE === 'true',
28
99
  showBrowser: process.env?.SHOW_BROWSER === 'true',
29
100
  });
101
+
30
102
  scraper.onProgress((_companyId, payload) => {
31
103
  log(payload.type);
32
104
  });
@@ -36,95 +108,136 @@ export async function scrapeAndImportTransactions({companyId, bank}: ScrapeTrans
36
108
  throw new Error(`Failed to scrape (${result.errorType}): ${result.errorMessage}`);
37
109
  }
38
110
 
39
- const transactions = _(result.accounts)
40
- .filter(account => account.txns.length > 0)
41
- .flatMap(account => account.txns)
42
- .value();
111
+ log('ACCOUNTS', {
112
+ accounts: result.accounts?.map(x => ({
113
+ accountNumber: x.accountNumber,
114
+ balance: x.balance,
115
+ txns: x.txns.length,
116
+ })),
117
+ });
118
+
119
+ const payees: PayeeEntity[] = await actual.getPayees();
43
120
 
44
- for (const account of result.accounts!) {
45
- if (account.txns.length <= 0) {
121
+ // Process each target independently (supports per-card, per-company, and consolidated).
122
+ for (const target of targets) {
123
+ const selectedAccounts = selectScraperAccounts(result.accounts as any[], target.accounts);
124
+
125
+ // Transactions to import: selected accounts with txns.
126
+ const transactions = _(selectedAccounts)
127
+ .filter(a => Array.isArray(a.txns) && a.txns.length > 0)
128
+ .flatMap(a => a.txns.map((t: any) => ({txn: t, accountNumber: String(a.accountNumber)})))
129
+ .value();
130
+
131
+ if (transactions.length === 0) {
132
+ log('NO_TRANSACTIONS', {actualAccountId: target.actualAccountId});
133
+ } else {
134
+ const mappedTransactions = transactions.map(async ({txn, accountNumber}) => stripUndefined({
135
+ date: moment(txn.date).format('YYYY-MM-DD'),
136
+ amount: actual.utils.amountToInteger(txn.chargedAmount),
137
+ payee: _.find(payees, {name: txn.description})?.id ?? (await actual.createPayee({name: txn.description})),
138
+ imported_payee: txn.description,
139
+ notes: txn.memo,
140
+ imported_id: stableImportedId(companyId, accountNumber, txn),
141
+ }));
142
+
143
+ stdout.mute();
144
+ const importResult = await actual.importTransactions(
145
+ target.actualAccountId,
146
+ await Promise.all(mappedTransactions),
147
+ {defaultCleared: true},
148
+ );
149
+ stdout.unmute();
150
+
151
+ if (_.isEmpty(importResult)) {
152
+ console.error('Errors', importResult.errors);
153
+ throw new Error('Failed to import transactions');
154
+ } else {
155
+ log('IMPORTED', {actualAccountId: target.actualAccountId, transactions: importResult.added.length});
156
+ }
157
+ }
158
+
159
+ if (!target.reconcile) {
46
160
  continue;
47
161
  }
48
- }
49
162
 
50
- const accountBalance = result.accounts![0].balance!;
51
- const payees: PayeeEntity[] = await actual.getPayees();
163
+ // Reconciliation balance: sum finite balances of selected accounts.
164
+ const reconAccounts = selectedAccounts
165
+ .filter(a => isFiniteNumber(a?.balance))
166
+ .map(a => ({accountNumber: String(a.accountNumber), balance: a.balance as number}));
52
167
 
53
- const mappedTransactions = transactions.map(async x => ({
54
- date: moment(x.date).format('YYYY-MM-DD'),
55
- amount: actual.utils.amountToInteger(x.chargedAmount),
56
- payee: _.find(payees, {name: x.description})?.id ?? (await actual.createPayee({name: x.description})),
57
- imported_payee: x.description,
58
- notes: x.memo,
59
- imported_id: `${x.identifier}-${moment(x.date).format('YYYY-MM-DD HH:mm:ss')}`,
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
- }
168
+ if (reconAccounts.length === 0) {
169
+ log('RECONCILE_SKIPPED_NO_BALANCES', {
170
+ actualAccountId: target.actualAccountId,
171
+ selectedAccounts: selectedAccounts.map(a => a?.accountNumber),
172
+ });
173
+ continue;
174
+ }
72
175
 
73
- if (!bank.reconcile) {
74
- return;
75
- }
176
+ const scraperBalance = reconAccounts.reduce((sum, a) => sum + a.balance, 0);
76
177
 
77
- const currentBalance = actual.utils.integerToAmount(await actual.getAccountBalance(bank.actualAccountId));
78
- const balanceDiff = accountBalance - currentBalance;
178
+ log('RECONCILE_INPUT', {
179
+ actualAccountId: target.actualAccountId,
180
+ accounts: reconAccounts,
181
+ balance: scraperBalance,
182
+ });
79
183
 
80
- // Use a stable imported_id per account so we can find and update/delete the same
81
- // reconciliation transaction instead of creating a new one every run.
82
- const reconciliationImportedId = `reconciliation-${bank.actualAccountId}`;
184
+ const currentBalance = actual.utils.integerToAmount(await actual.getAccountBalance(target.actualAccountId));
185
+ const balanceDiff = scraperBalance - currentBalance;
83
186
 
84
- // Fetch all transactions for this account and look for an existing reconciliation.
85
- // Use a wide date range so we always find it if it exists.
86
- const allAccountTxns: TransactionEntity[] = await actual.getTransactions(
87
- bank.actualAccountId,
88
- '2000-01-01',
89
- moment().add(1, 'year').format('YYYY-MM-DD'),
90
- );
187
+ // Stable imported_id per Actual account so we update the same reconciliation txn each run.
188
+ const reconciliationImportedId = `reconciliation-${target.actualAccountId}`;
91
189
 
92
- const existingReconciliation = allAccountTxns.find(txn => txn.imported_id === reconciliationImportedId);
190
+ const allAccountTxns: TransactionEntity[] = await actual.getTransactions(
191
+ target.actualAccountId,
192
+ '2000-01-01',
193
+ moment().add(1, 'year').format('YYYY-MM-DD'),
194
+ );
93
195
 
94
- // If balances are already in sync, no need to create/update reconciliation.
95
- if (existingReconciliation && balanceDiff === 0) {
96
- log('RECONCILIATION_NOT_NEEDED');
97
- return;
98
- }
196
+ const existingReconciliation = allAccountTxns.find(txn => txn.imported_id === reconciliationImportedId);
99
197
 
100
- const reconciliationTxn = {
101
- account: bank.actualAccountId,
102
- date: moment().format('YYYY-MM-DD'),
103
- amount: actual.utils.amountToInteger(balanceDiff),
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();
198
+ if (existingReconciliation && balanceDiff === 0) {
199
+ log('RECONCILIATION_NOT_NEEDED', {actualAccountId: target.actualAccountId});
200
+ continue;
201
+ }
114
202
 
115
- log('RECONCILIATION_UPDATED', {from: currentBalance, to: accountBalance, diff: balanceDiff});
116
- return;
117
- }
203
+ const reconciliationTxn = stripUndefined({
204
+ account: target.actualAccountId,
205
+ date: moment().format('YYYY-MM-DD'),
206
+ amount: actual.utils.amountToInteger(balanceDiff),
207
+ payee: null, // IMPORTANT: never pass undefined to updateTransaction schema
208
+ imported_payee: 'Reconciliation',
209
+ notes: `Reconciliation from ${currentBalance.toLocaleString()} to ${scraperBalance.toLocaleString()}`,
210
+ imported_id: reconciliationImportedId,
211
+ });
212
+
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
+ }
118
226
 
119
- // Create the reconciliation transaction for the first time
120
- stdout.mute();
121
- const reconciliationResult = await actual.importTransactions(bank.actualAccountId, [reconciliationTxn]);
122
- stdout.unmute();
227
+ stdout.mute();
228
+ const reconciliationResult = await actual.importTransactions(target.actualAccountId, [reconciliationTxn]);
229
+ stdout.unmute();
123
230
 
124
- if (!reconciliationResult || _.isEmpty(reconciliationResult.added)) {
125
- console.error('Reconciliation errors', reconciliationResult?.errors);
126
- } else {
127
- log('RECONCILIATION_ADDED', {from: currentBalance, to: accountBalance, diff: balanceDiff});
231
+ if (!reconciliationResult || _.isEmpty(reconciliationResult.added)) {
232
+ console.error('Reconciliation errors', reconciliationResult?.errors);
233
+ } else {
234
+ log('RECONCILIATION_ADDED', {
235
+ actualAccountId: target.actualAccountId,
236
+ from: currentBalance,
237
+ to: scraperBalance,
238
+ diff: balanceDiff,
239
+ });
240
+ }
128
241
  }
129
242
  } catch (error) {
130
243
  console.error('Error', companyId, error);