israeli-banks-actual-budget-importer 1.0.0-beta.1 → 1.0.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/package.json CHANGED
@@ -1,7 +1,12 @@
1
1
  {
2
+ "version": "1.0.0",
2
3
  "name": "israeli-banks-actual-budget-importer",
3
4
  "module": "index.ts",
4
5
  "type": "module",
6
+ "scripts": {
7
+ "start": "NODE_TLS_REJECT_UNAUTHORIZED=0 tsx src/index.ts",
8
+ "test": "xo"
9
+ },
5
10
  "devDependencies": {
6
11
  "@codedependant/semantic-release-docker": "^5.1.0",
7
12
  "@semantic-release/changelog": "^6.0.3",
@@ -10,15 +15,20 @@
10
15
  "@semantic-release/github": "^11.0.1",
11
16
  "@semantic-release/npm": "^12.0.1",
12
17
  "@semantic-release/release-notes-generator": "^14.0.3",
18
+ "@types/lodash": "^4.17.16",
19
+ "@types/papaparse": "^5.3.15",
13
20
  "bun-types": "latest",
21
+ "papaparse": "^5.5.2",
22
+ "typescript": "^5.8.2",
14
23
  "xo": "^0.60.0"
15
24
  },
16
- "peerDependencies": {
17
- "typescript": "^5.8.2"
18
- },
19
- "version": "1.0.0-beta.1",
20
- "scripts": {
21
- "test": "xo"
22
- },
23
- "packageManager": "yarn@4.7.0"
25
+ "packageManager": "yarn@4.7.0",
26
+ "dependencies": {
27
+ "@actual-app/api": "^25.3.1",
28
+ "israeli-bank-scrapers": "^5.4.5",
29
+ "lodash": "^4.17.21",
30
+ "moment": "^2.30.1",
31
+ "p-queue": "^8.1.0",
32
+ "tsx": "^4.19.3"
33
+ }
24
34
  }
@@ -0,0 +1,26 @@
1
+ import type {InitConfig} from '@actual-app/api/@types/loot-core/server/main';
2
+ import type {ScraperCredentials, CompanyTypes} from 'israeli-bank-scrapers';
3
+
4
+ export type Config = {
5
+ banks: ConfigBanks;
6
+ actual: ConfigActual;
7
+ };
8
+
9
+ export type ConfigActual = {
10
+ init: InitConfig;
11
+ budget: ConfigActualBudget;
12
+ };
13
+
14
+ export type ConfigActualBudget = {
15
+ syncId: string;
16
+ password: string;
17
+ };
18
+
19
+ export type ConfigBanks = Partial<{
20
+ [key in CompanyTypes]: ConfigBank;
21
+ }>;
22
+
23
+ export type ConfigBank = ScraperCredentials & {
24
+ actualAccountId: string;
25
+ reconcile?: boolean;
26
+ };
package/src/index.ts CHANGED
@@ -0,0 +1,27 @@
1
+ /* eslint-disable no-await-in-loop */
2
+ /* eslint-disable n/file-extension-in-import */
3
+
4
+ import {type CompanyTypes} from 'israeli-bank-scrapers';
5
+ import _ from 'lodash';
6
+ import actual from '@actual-app/api';
7
+ import Queue from 'p-queue';
8
+ import {config, type ConfigBank} from '../config.ts';
9
+ import {scrapeAndImportTransactions} from './utils.ts';
10
+
11
+ const queue = new Queue({
12
+ concurrency: 10,
13
+ autoStart: true,
14
+ interval: 1000,
15
+ intervalCap: 10,
16
+ });
17
+
18
+ await actual.init(config.actual.init);
19
+ await actual.downloadBudget(config.actual.budget.syncId, config.actual.budget);
20
+
21
+ for (const [companyId, bank] of _.entries(config.banks) as Array<[CompanyTypes, ConfigBank]>) {
22
+ await queue.add(async () => scrapeAndImportTransactions({companyId, bank}));
23
+ }
24
+
25
+ await queue.onIdle();
26
+ await actual.shutdown();
27
+ console.log('Done');
package/src/utils.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ import type {ScraperCredentials, CompanyTypes} from 'israeli-bank-scrapers';
2
+ import type actual from '@actual-app/api';
3
+ import {type ConfigBank} from '../config.js';
4
+
5
+ export type ScrapeTransactionsContext = {
6
+ companyId: CompanyTypes;
7
+ bank: ConfigBank;
8
+ };
package/src/utils.ts ADDED
@@ -0,0 +1,92 @@
1
+ /* eslint-disable @typescript-eslint/naming-convention */
2
+ /* eslint-disable @typescript-eslint/no-unsafe-assignment */
3
+
4
+ /* eslint-disable import/extensions */
5
+ /* eslint-disable n/file-extension-in-import */
6
+ import {createScraper, type ScraperCredentials} from 'israeli-bank-scrapers';
7
+ import _ from 'lodash';
8
+ import moment from 'moment';
9
+ import actual from '@actual-app/api';
10
+ import {type PayeeEntity, type TransactionEntity} from '@actual-app/api/@types/loot-core/types/models';
11
+ import {type ScrapeTransactionsContext} from './utils.d';
12
+
13
+ export async function scrapeAndImportTransactions({companyId, bank}: ScrapeTransactionsContext) {
14
+ try {
15
+ const scraper = createScraper({
16
+ companyId,
17
+ startDate: moment().subtract(6, 'month').toDate(),
18
+ executablePath: '/opt/homebrew/bin/chromium',
19
+ additionalTransactionInformation: true,
20
+ verbose: false,
21
+ showBrowser: false,
22
+ });
23
+ scraper.onProgress((companyId, payload) => {
24
+ console.debug('Progress', companyId, payload);
25
+ });
26
+
27
+ const result = await scraper.scrape(bank as ScraperCredentials);
28
+ if (!result.success) {
29
+ throw new Error(`Failed to scrape (${result.errorType}): ${result.errorMessage}`);
30
+ }
31
+
32
+ const transactions = _(result.accounts)
33
+ .filter(account => account.txns.length > 0)
34
+ .flatMap(account => account.txns)
35
+ .value();
36
+
37
+ for (const account of result.accounts!) {
38
+ if (account.txns.length <= 0) {
39
+ continue;
40
+ }
41
+ }
42
+
43
+ const accounts = await actual.getAccounts() as TransactionEntity[];
44
+ const account = _.find(accounts, {id: bank.actualAccountId})!;
45
+ const accountBalance = result.accounts![0].balance!;
46
+ console.log('Account', account, 'Balance', accountBalance);
47
+
48
+ const payees: PayeeEntity[] = await actual.getPayees();
49
+ const mappedTransactions = transactions.map(async x => ({
50
+ date: moment(x.date).format('YYYY-MM-DD'),
51
+ amount: actual.utils.amountToInteger(x.chargedAmount),
52
+ payee: _.find(payees, {name: x.description})?.id ?? (await actual.createPayee({name: x.description})),
53
+ imported_payee: x.description,
54
+ notes: x.status,
55
+ }));
56
+
57
+ const importResult = await actual.importTransactions(bank.actualAccountId, await Promise.all(mappedTransactions), {defaultCleared: true});
58
+ if (_.isEmpty(importResult)) {
59
+ console.error('Errors', importResult.errors);
60
+ throw new Error('Failed to import transactions');
61
+ } else {
62
+ console.log('Imported', importResult.added, 'transactions');
63
+ }
64
+
65
+ if (!bank.reconcile) {
66
+ return;
67
+ }
68
+
69
+ const currentBalance = actual.utils.integerToAmount(await actual.getAccountBalance(bank.actualAccountId));
70
+ const balanceDiff = accountBalance - currentBalance;
71
+ if (balanceDiff === 0) {
72
+ return;
73
+ }
74
+
75
+ console.log('Balance diff', balanceDiff);
76
+ const reconciliationResult = await actual.importTransactions(bank.actualAccountId, [{
77
+ date: moment().format('YYYY-MM-DD'),
78
+ amount: actual.utils.amountToInteger(balanceDiff),
79
+ payee: null,
80
+ imported_payee: 'Reconciliation',
81
+ notes: `Reconciliation from ${currentBalance.toLocaleString()} to ${accountBalance.toLocaleString()}`,
82
+ }]);
83
+ if (_.isEmpty(reconciliationResult)) {
84
+ console.error('Reconciliation errors', reconciliationResult.errors);
85
+ } else {
86
+ console.info('Added a reconciliation transaction from', currentBalance, 'to', accountBalance);
87
+ }
88
+ } catch (error) {
89
+ console.error('Error', companyId, error);
90
+ }
91
+ }
92
+