From b2d218759585efd7a4973801cdebd9ebe7f72e5b Mon Sep 17 00:00:00 2001 From: gczobel <754466+gczobel@users.noreply.github.com> Date: Wed, 10 Jan 2024 22:09:06 +0200 Subject: [PATCH 1/5] feat: ynab support --- README.md | 26 ++++++++++- package-lock.json | 47 +++++++++++++++++++- package.json | 4 +- src/config.ts | 12 ++++- src/storage/index.ts | 2 + src/storage/ynab.ts | 103 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 190 insertions(+), 4 deletions(-) create mode 100644 src/storage/ynab.ts diff --git a/README.md b/README.md index 42d218de..2d8e296b 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Internally we use [israeli-bank-scrapers](https://github.com/eshaham/israeli-ban ## Why? -Having all your data in one place lets you view all of your expenses in a beautiful dashboard like [Google Data Studio](https://datastudio.google.com), [Azure Data Explorer dashboards](https://docs.microsoft.com/en-us/azure/data-explorer/azure-data-explorer-dashboards) and [Microsoft Power BI](https://powerbi.microsoft.com/) +Having all your data in one place lets you view all of your expenses in a beautiful dashboard like [Google Data Studio](https://datastudio.google.com), [Azure Data Explorer dashboards](https://docs.microsoft.com/en-us/azure/data-explorer/azure-data-explorer-dashboards), [Microsoft Power BI](https://powerbi.microsoft.com/) and [YNAB](https://www.ynab.com/). ## Important notes @@ -182,3 +182,27 @@ Use the following env vars to setup: | `GOOGLE_SERVICE_ACCOUNT_EMAIL` | The service account's email address | | `GOOGLE_SHEET_ID` | The id of the spreadsheet you shared with the service account | | `WORKSHEET_NAME` | The name of the sheet you want to add the transactions to | + +### Export to YNAB (YouNeedABudget) + +To export your transactions directly to `YNAB` you need to use the following environment variables to setup: +| env variable name | description | +| ------------------------------------ | ------------------------------------------------------------- | +| `YNAB_TOKEN` | The `YNAB` access token. Check [YNAB documentation](https://api.ynab.com/#authentication) about how to obtain it | +| `YNAB_BUDGET_ID` | The `YNAB` budget ID where you want to import the data. You can obtain it opening [YNAB application](https://app.ynab.com/) on a browser and taking the budget `UUID` in the `URL` | +| `YNAB_ACCOUNTS` | A key-value list to correlate each account with the `YNAB` account `UUID` | + +#### YNAB_ACCOUNTS + +A `JSON` key-value pair structure representing a mapping between two identifiers. The `key` represent the account ID as is understood by moneyman and the `value` it's the `UUID` visible in the YNAB URL when an account is selected. + +For example, in the URL: +`https://app.ynab.com/22aa9fcd-93a9-47e9-8ff6-33036b7c6242/accounts/ba2dd3a9-b7d4-46d6-8413-8327203e2b82` the account UUID is the second `UUID`. + +Example: + +```json +{ + "5897": "ba2dd3a9-b7d4-46d6-8413-8327203e2b82" +} +``` diff --git a/package-lock.json b/package-lock.json index d1d8b1d1..a1292f83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,8 +17,10 @@ "dotenv": "^16.3.1", "google-auth-library": "^9.4.2", "google-spreadsheet": "^4.1.1", + "hash-it": "^6.0.0", "israeli-bank-scrapers": "^4.1.1", - "telegraf": "^4.15.3" + "telegraf": "^4.15.3", + "ynab": "^2.2.0" }, "devDependencies": { "@types/debug": "^4.1.12", @@ -2634,6 +2636,33 @@ "pend": "~1.2.0" } }, + "node_modules/fetch-ponyfill": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/fetch-ponyfill/-/fetch-ponyfill-7.1.0.tgz", + "integrity": "sha512-FhbbL55dj/qdVO3YNK7ZEkshvj3eQ7EuIGV2I6ic/2YiocvyWv+7jg2s4AyS0wdRU75s3tA8ZxI/xPigb0v5Aw==", + "dependencies": { + "node-fetch": "~2.6.1" + } + }, + "node_modules/fetch-ponyfill/node_modules/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-StxNAxh15zr77QvvkmveSQ8uCQ4+v5FkvNTj0OESmiHu+VRi/gXArXtkWMElOsOUNLtUEvI4yS+rdtOHZTwlQA==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -2945,6 +2974,11 @@ "node": ">=8" } }, + "node_modules/hash-it": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/hash-it/-/hash-it-6.0.0.tgz", + "integrity": "sha512-KHzmSFx1KwyMPw0kXeeUD752q/Kfbzhy6dAZrjXV9kAIXGqzGvv8vhkUqj+2MGZldTo0IBpw6v7iWE7uxsvH0w==" + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -6044,6 +6078,17 @@ "fd-slicer": "~1.1.0" } }, + "node_modules/ynab": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ynab/-/ynab-2.2.0.tgz", + "integrity": "sha512-07RFqEqhoIxge1JzYriMDxTyX2yEhKhUxHATzIx2V81hQYQs0UV7ieZ5dvUaSbE+05wWAg+tbScfh2PxghmkwA==", + "dependencies": { + "fetch-ponyfill": "^7.1.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index c33d6b20..7883b3d7 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,10 @@ "dotenv": "^16.3.1", "google-auth-library": "^9.4.2", "google-spreadsheet": "^4.1.1", + "hash-it": "^6.0.0", "israeli-bank-scrapers": "^4.1.1", - "telegraf": "^4.15.3" + "telegraf": "^4.15.3", + "ynab": "^2.2.0" }, "devDependencies": { "@types/debug": "^4.1.12", diff --git a/src/config.ts b/src/config.ts index 563ccaf6..6f21a1f3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -17,6 +17,9 @@ const { WORKSHEET_NAME, ACCOUNTS_TO_SCRAPE = "", FUTURE_MONTHS = "", + YNAB_TOKEN = "", + YNAB_BUDGET_ID = "", + YNAB_ACCOUNTS = "", } = process.env; /** @@ -31,7 +34,14 @@ const accountsToScrape = ACCOUNTS_TO_SCRAPE.split(",") .filter(Boolean) .map((a) => a.trim()); -export { TELEGRAM_API_KEY, TELEGRAM_CHAT_ID, GOOGLE_SHEET_ID }; +export { + TELEGRAM_API_KEY, + TELEGRAM_CHAT_ID, + GOOGLE_SHEET_ID, + YNAB_TOKEN, + YNAB_BUDGET_ID, + YNAB_ACCOUNTS, +}; export const systemName = "moneyman"; export const currentDate = format(Date.now(), "yyyy-MM-dd"); export const scrapeStartDate = subDays(Date.now(), Number(daysBackToScrape)); diff --git a/src/storage/index.ts b/src/storage/index.ts index b53673d5..801fd35d 100644 --- a/src/storage/index.ts +++ b/src/storage/index.ts @@ -4,11 +4,13 @@ import { LocalJsonStorage } from "./json.js"; import { GoogleSheetsStorage } from "./sheets.js"; import { AzureDataExplorerStorage } from "./azure-data-explorer.js"; import { transactionHash } from "./utils.js"; +import { YNABStorage } from "./ynab.js"; export const storages = [ new LocalJsonStorage(), new GoogleSheetsStorage(), new AzureDataExplorerStorage(), + new YNABStorage(), ].filter((s) => s.canSave()); export async function initializeStorage() { diff --git a/src/storage/ynab.ts b/src/storage/ynab.ts new file mode 100644 index 00000000..44e7b651 --- /dev/null +++ b/src/storage/ynab.ts @@ -0,0 +1,103 @@ +import { YNAB_TOKEN, YNAB_BUDGET_ID, YNAB_ACCOUNTS } from "../config.js"; +import { SaveStats, TransactionRow, TransactionStorage } from "../types.js"; +import { createLogger } from "./../utils/logger.js"; +import { parseISO, format } from "date-fns"; +import * as ynab from "ynab"; +import hash from "hash-it"; + +const YNAB_DATE_FORMAT = "yyyy-MM-dd"; +let ynabAPI: ynab.API; + +const logger = createLogger("YNABStorage"); + +export class YNABStorage implements TransactionStorage { + async init() { + logger("init"); + } + + canSave() { + return Boolean(YNAB_TOKEN && YNAB_BUDGET_ID); + } + + async saveTransactions(txns: Array) { + await this.init(); + + ynabAPI = new ynab.API(YNAB_TOKEN); + const budgetName = await getBudgetName(YNAB_BUDGET_ID); + + // Convert transactions to YNAB format + logger("transforming transactions to ynab format"); + const transactionsFromFinancialAccount = txns.map( + convertTransactionToYnabFormat, + ); + + // Send transactions to YNAB + logger(`sending to YNAB budget: "${budgetName}"`); + await ynabAPI.transactions.createTransactions(YNAB_BUDGET_ID, { + transactions: transactionsFromFinancialAccount, + }); + logger("transactions sent to YNAB successfully!"); + + const stats: SaveStats = { + name: "YNABStorage", + table: `budget: "${budgetName}"`, + total: txns.length, + added: txns.length, + pending: NaN, + skipped: 0, + existing: NaN, + }; + + return stats; + } +} + +function convertTransactionToYnabFormat( + tx: TransactionRow, +): ynab.SaveTransaction { + const amount = Math.round(tx.chargedAmount * 1000); + + return { + account_id: getYnabAccountIdByAccountNumberFromTransaction(tx.account), + date: format(parseISO(tx.date), YNAB_DATE_FORMAT, {}), + amount, + payee_id: null, + payee_name: tx.description, + cleared: ynab.TransactionClearedStatus.Cleared, + approved: false, + import_id: hash(tx.hash).toString(), + memo: tx.memo, + }; +} + +function getYnabAccountIdByAccountNumberFromTransaction( + transactionAccountNumber: string, +): string { + let jsonData: any; + try { + jsonData = JSON.parse(YNAB_ACCOUNTS); + } catch (parseError) { + const customError = new Error( + `Error parsing JSON in YNAB_ACCOUNTS ': ${parseError.message}`, + ); + throw customError; + } + + const ynabAccountId = jsonData[transactionAccountNumber]; + if (!ynabAccountId) { + throw new Error( + `Cannot found YNAB account UUID for account number ${transactionAccountNumber}`, + ); + } + return ynabAccountId; +} + +async function getBudgetName(budgetId: string) { + const budgetResponse = await ynabAPI.budgets.getBudgetById(budgetId); + if (budgetResponse.data) { + const budgetName = budgetResponse.data.budget.name; + return budgetName; + } else { + throw new Error(`YNAB_BUDGET_ID does not exists in YNAB ${budgetId}`); + } +} From df35b4dd1c7a93e1bf602afedb4ff6ae88bd700f Mon Sep 17 00:00:00 2001 From: gczobel <754466+gczobel@users.noreply.github.com> Date: Fri, 12 Jan 2024 16:43:55 +0200 Subject: [PATCH 2/5] fix: added grace to the fails --- .vscode/settings.json | 2 +- src/storage/ynab.ts | 135 ++++++++++++++++++++++++------------------ 2 files changed, 77 insertions(+), 60 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 672fc65e..7c61da84 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,5 @@ { - "cSpell.words": ["Kusto", "moneyman", "MULTIJSON", "nektos", "txns"], + "cSpell.words": ["Kusto", "moneyman", "MULTIJSON", "nektos", "txns", "ynab"], "yaml.schemas": { "https://json.schemastore.org/github-workflow.json": "file:///workspaces/money/.github/workflows/build.yml" } diff --git a/src/storage/ynab.ts b/src/storage/ynab.ts index 44e7b651..dec6393e 100644 --- a/src/storage/ynab.ts +++ b/src/storage/ynab.ts @@ -6,13 +6,18 @@ import * as ynab from "ynab"; import hash from "hash-it"; const YNAB_DATE_FORMAT = "yyyy-MM-dd"; -let ynabAPI: ynab.API; - const logger = createLogger("YNABStorage"); export class YNABStorage implements TransactionStorage { + private ynabAPI: ynab.API; + private budgetName: string; + private accountToYnabAccount: Map; + async init() { logger("init"); + this.ynabAPI = new ynab.API(YNAB_TOKEN); + this.budgetName = await this.getBudgetName(YNAB_BUDGET_ID); + this.accountToYnabAccount = this.parseYnabAccounts(YNAB_ACCOUNTS); } canSave() { @@ -22,82 +27,94 @@ export class YNABStorage implements TransactionStorage { async saveTransactions(txns: Array) { await this.init(); - ynabAPI = new ynab.API(YNAB_TOKEN); - const budgetName = await getBudgetName(YNAB_BUDGET_ID); + // Converting to YNAB format. + const transactionsFromFinancialAccount = txns.map((tx) => + this.convertTransactionToYnabFormat(tx), + ); - // Convert transactions to YNAB format - logger("transforming transactions to ynab format"); - const transactionsFromFinancialAccount = txns.map( - convertTransactionToYnabFormat, + // Filter out transactions with no account number + const transactionsWithAccount = transactionsFromFinancialAccount.filter( + (tx) => tx.account_id !== "", ); // Send transactions to YNAB - logger(`sending to YNAB budget: "${budgetName}"`); - await ynabAPI.transactions.createTransactions(YNAB_BUDGET_ID, { - transactions: transactionsFromFinancialAccount, - }); + logger(`sending to YNAB budget: "${this.budgetName}"`); + const resp = await this.ynabAPI.transactions.createTransactions( + YNAB_BUDGET_ID, + { + transactions: transactionsWithAccount, + }, + ); logger("transactions sent to YNAB successfully!"); + const dups = resp.data.duplicate_import_ids + ? resp.data.duplicate_import_ids.length + : 0; + const noID = txns.length - transactionsWithAccount.length; + const stats: SaveStats = { name: "YNABStorage", - table: `budget: "${budgetName}"`, - total: txns.length, - added: txns.length, + table: `budget: "${this.budgetName}"`, + total: transactionsFromFinancialAccount.length, + added: resp.data.transactions ? resp.data.transactions.length : 0, pending: NaN, - skipped: 0, - existing: NaN, + skipped: noID + dups, + existing: dups, }; return stats; } -} -function convertTransactionToYnabFormat( - tx: TransactionRow, -): ynab.SaveTransaction { - const amount = Math.round(tx.chargedAmount * 1000); - - return { - account_id: getYnabAccountIdByAccountNumberFromTransaction(tx.account), - date: format(parseISO(tx.date), YNAB_DATE_FORMAT, {}), - amount, - payee_id: null, - payee_name: tx.description, - cleared: ynab.TransactionClearedStatus.Cleared, - approved: false, - import_id: hash(tx.hash).toString(), - memo: tx.memo, - }; -} + private async getBudgetName(budgetId: string) { + const budgetResponse = await this.ynabAPI.budgets.getBudgetById(budgetId); + if (budgetResponse.data) { + return budgetResponse.data.budget.name; + } else { + throw new Error(`YNAB_BUDGET_ID does not exist in YNAB: ${budgetId}`); + } + } -function getYnabAccountIdByAccountNumberFromTransaction( - transactionAccountNumber: string, -): string { - let jsonData: any; - try { - jsonData = JSON.parse(YNAB_ACCOUNTS); - } catch (parseError) { - const customError = new Error( - `Error parsing JSON in YNAB_ACCOUNTS ': ${parseError.message}`, - ); - throw customError; + private parseYnabAccounts(accountsJSON: string): Map { + let jsonData: any; + try { + jsonData = JSON.parse(accountsJSON); + } catch (parseError) { + const customError = new Error( + `Error parsing JSON in YNAB_ACCOUNTS: ${parseError.message}`, + ); + throw customError; + } + + return new Map(Object.entries(jsonData)); } - const ynabAccountId = jsonData[transactionAccountNumber]; - if (!ynabAccountId) { - throw new Error( - `Cannot found YNAB account UUID for account number ${transactionAccountNumber}`, + private convertTransactionToYnabFormat( + tx: TransactionRow, + ): ynab.SaveTransaction { + const amount = Math.round(tx.chargedAmount * 1000); + const accountId = this.getYnabAccountIdByAccountNumberFromTransaction( + tx.account, ); + + return { + account_id: accountId === null ? "" : accountId, + date: format(parseISO(tx.date), YNAB_DATE_FORMAT, {}), + amount, + payee_id: null, + payee_name: tx.description, + cleared: ynab.TransactionClearedStatus.Cleared, + approved: false, + import_id: hash(tx.hash).toString(), + memo: tx.memo, + }; } - return ynabAccountId; -} -async function getBudgetName(budgetId: string) { - const budgetResponse = await ynabAPI.budgets.getBudgetById(budgetId); - if (budgetResponse.data) { - const budgetName = budgetResponse.data.budget.name; - return budgetName; - } else { - throw new Error(`YNAB_BUDGET_ID does not exists in YNAB ${budgetId}`); + private getYnabAccountIdByAccountNumberFromTransaction( + transactionAccountNumber: string, + ): string | null { + const ynabAccountId = this.accountToYnabAccount.get( + transactionAccountNumber, + ); + return ynabAccountId || null; } } From 33c5c14a1078aca9a0fd5f292ea896addd25fd3e Mon Sep 17 00:00:00 2001 From: gczobel <754466+gczobel@users.noreply.github.com> Date: Sat, 20 Jan 2024 10:39:56 +0200 Subject: [PATCH 3/5] fix: skipped tx and stats --- src/storage/ynab.ts | 77 ++++++++++++++++++++++++++++++++------------- 1 file changed, 55 insertions(+), 22 deletions(-) diff --git a/src/storage/ynab.ts b/src/storage/ynab.ts index dec6393e..d77cdf9f 100644 --- a/src/storage/ynab.ts +++ b/src/storage/ynab.ts @@ -4,6 +4,7 @@ import { createLogger } from "./../utils/logger.js"; import { parseISO, format } from "date-fns"; import * as ynab from "ynab"; import hash from "hash-it"; +import { TransactionStatuses } from "israeli-bank-scrapers/lib/transactions.js"; const YNAB_DATE_FORMAT = "yyyy-MM-dd"; const logger = createLogger("YNABStorage"); @@ -12,6 +13,7 @@ export class YNABStorage implements TransactionStorage { private ynabAPI: ynab.API; private budgetName: string; private accountToYnabAccount: Map; + private alreadyIssuedWarnings: Set = new Set(); async init() { logger("init"); @@ -27,40 +29,60 @@ export class YNABStorage implements TransactionStorage { async saveTransactions(txns: Array) { await this.init(); - // Converting to YNAB format. - const transactionsFromFinancialAccount = txns.map((tx) => - this.convertTransactionToYnabFormat(tx), - ); - - // Filter out transactions with no account number - const transactionsWithAccount = transactionsFromFinancialAccount.filter( - (tx) => tx.account_id !== "", - ); + const stats = { + name: "YNABStorage", + table: `budget: "${this.budgetName}"`, + total: txns.length, + added: 0, + pending: 0, + existing: 0, + skipped: 0, + highlightedTransactions: { + Added: [] as Array, + }, + } satisfies SaveStats; + + // Initialize an array to store non-pending and non-empty account ID transactions on YNAB format. + const txToSend: ynab.SaveTransaction[] = []; + + for (let tx of txns) { + if (tx.status === TransactionStatuses.Pending) { + stats.pending++; + stats.skipped++; + continue; + } + + // Converting to YNAB format. + const yTx = this.convertTransactionToYnabFormat(tx); + + if (yTx.account_id === "") { + //stats.pending++; + stats.skipped++; + continue; + } + + // Add non-pending and non-empty account ID transactions to the array. + txToSend.push(yTx); + //stats.highlightedTransactions.Added.push(tx); + } // Send transactions to YNAB logger(`sending to YNAB budget: "${this.budgetName}"`); const resp = await this.ynabAPI.transactions.createTransactions( YNAB_BUDGET_ID, { - transactions: transactionsWithAccount, + transactions: txToSend, }, ); logger("transactions sent to YNAB successfully!"); - const dups = resp.data.duplicate_import_ids + const existingTxs = resp.data.duplicate_import_ids ? resp.data.duplicate_import_ids.length : 0; - const noID = txns.length - transactionsWithAccount.length; - const stats: SaveStats = { - name: "YNABStorage", - table: `budget: "${this.budgetName}"`, - total: transactionsFromFinancialAccount.length, - added: resp.data.transactions ? resp.data.transactions.length : 0, - pending: NaN, - skipped: noID + dups, - existing: dups, - }; + stats.added = resp.data.transactions ? resp.data.transactions.length : 0; + stats.existing = existingTxs; + stats.skipped = stats.skipped + existingTxs; return stats; } @@ -96,13 +118,24 @@ export class YNABStorage implements TransactionStorage { tx.account, ); + if (accountId === null) { + const warningMessage = `Some Txs will be skipped. Account ID not found for account number: ${tx.account}`; + if (!this.alreadyIssuedWarnings.has(warningMessage)) { + logger(`Warning: ${warningMessage}`); + this.alreadyIssuedWarnings.add(warningMessage); + } + } + return { account_id: accountId === null ? "" : accountId, date: format(parseISO(tx.date), YNAB_DATE_FORMAT, {}), amount, payee_id: null, payee_name: tx.description, - cleared: ynab.TransactionClearedStatus.Cleared, + cleared: + tx.status === TransactionStatuses.Completed + ? ynab.TransactionClearedStatus.Cleared + : undefined, approved: false, import_id: hash(tx.hash).toString(), memo: tx.memo, From fb376a0a4d61a087fd7e809508ece5d3e17d6014 Mon Sep 17 00:00:00 2001 From: Daniel Hauser Date: Sat, 27 Jan 2024 17:45:29 +0200 Subject: [PATCH 4/5] Code style changes --- src/storage/ynab.ts | 63 +++++++++++++++------------------------------ 1 file changed, 21 insertions(+), 42 deletions(-) diff --git a/src/storage/ynab.ts b/src/storage/ynab.ts index d77cdf9f..a9395909 100644 --- a/src/storage/ynab.ts +++ b/src/storage/ynab.ts @@ -13,7 +13,7 @@ export class YNABStorage implements TransactionStorage { private ynabAPI: ynab.API; private budgetName: string; private accountToYnabAccount: Map; - private alreadyIssuedWarnings: Set = new Set(); + private missingAccounts: Set = new Set(); async init() { logger("init"); @@ -37,9 +37,6 @@ export class YNABStorage implements TransactionStorage { pending: 0, existing: 0, skipped: 0, - highlightedTransactions: { - Added: [] as Array, - }, } satisfies SaveStats; // Initialize an array to store non-pending and non-empty account ID transactions on YNAB format. @@ -53,17 +50,14 @@ export class YNABStorage implements TransactionStorage { } // Converting to YNAB format. - const yTx = this.convertTransactionToYnabFormat(tx); - - if (yTx.account_id === "") { - //stats.pending++; + const ynabTx = this.convertTransactionToYnabFormat(tx); + if (!ynabTx.account_id) { stats.skipped++; continue; } // Add non-pending and non-empty account ID transactions to the array. - txToSend.push(yTx); - //stats.highlightedTransactions.Added.push(tx); + txToSend.push(ynabTx); } // Send transactions to YNAB @@ -76,13 +70,17 @@ export class YNABStorage implements TransactionStorage { ); logger("transactions sent to YNAB successfully!"); - const existingTxs = resp.data.duplicate_import_ids - ? resp.data.duplicate_import_ids.length - : 0; + if (this.missingAccounts.size > 0) { + logger( + `Accounts missing in YNAB_ACCOUNTS:`, + this.missingAccounts, + ); + this.missingAccounts.clear(); + } - stats.added = resp.data.transactions ? resp.data.transactions.length : 0; - stats.existing = existingTxs; - stats.skipped = stats.skipped + existingTxs; + stats.added = resp.data.transactions?.length ?? 0; + stats.existing = resp.data.duplicate_import_ids?.length ?? 0; + stats.skipped += stats.existing; return stats; } @@ -97,37 +95,27 @@ export class YNABStorage implements TransactionStorage { } private parseYnabAccounts(accountsJSON: string): Map { - let jsonData: any; try { - jsonData = JSON.parse(accountsJSON); + const accounts = JSON.parse(accountsJSON); + return new Map(Object.entries(accounts)); } catch (parseError) { - const customError = new Error( + throw new Error( `Error parsing JSON in YNAB_ACCOUNTS: ${parseError.message}`, ); - throw customError; } - - return new Map(Object.entries(jsonData)); } private convertTransactionToYnabFormat( tx: TransactionRow, ): ynab.SaveTransaction { const amount = Math.round(tx.chargedAmount * 1000); - const accountId = this.getYnabAccountIdByAccountNumberFromTransaction( - tx.account, - ); - - if (accountId === null) { - const warningMessage = `Some Txs will be skipped. Account ID not found for account number: ${tx.account}`; - if (!this.alreadyIssuedWarnings.has(warningMessage)) { - logger(`Warning: ${warningMessage}`); - this.alreadyIssuedWarnings.add(warningMessage); - } + const accountId = this.accountToYnabAccount.get(tx.account); + if (!accountId) { + this.missingAccounts.add(tx.account); } return { - account_id: accountId === null ? "" : accountId, + account_id: accountId ?? "", date: format(parseISO(tx.date), YNAB_DATE_FORMAT, {}), amount, payee_id: null, @@ -141,13 +129,4 @@ export class YNABStorage implements TransactionStorage { memo: tx.memo, }; } - - private getYnabAccountIdByAccountNumberFromTransaction( - transactionAccountNumber: string, - ): string | null { - const ynabAccountId = this.accountToYnabAccount.get( - transactionAccountNumber, - ); - return ynabAccountId || null; - } } From 8302d3dbb1b80c5c23c876aaeb3c5314d60eefc9 Mon Sep 17 00:00:00 2001 From: Daniel Hauser Date: Sat, 27 Jan 2024 17:51:58 +0200 Subject: [PATCH 5/5] Move missingAccounts to saveTransactions --- src/storage/ynab.ts | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/src/storage/ynab.ts b/src/storage/ynab.ts index a9395909..1c854565 100644 --- a/src/storage/ynab.ts +++ b/src/storage/ynab.ts @@ -13,7 +13,6 @@ export class YNABStorage implements TransactionStorage { private ynabAPI: ynab.API; private budgetName: string; private accountToYnabAccount: Map; - private missingAccounts: Set = new Set(); async init() { logger("init"); @@ -41,6 +40,7 @@ export class YNABStorage implements TransactionStorage { // Initialize an array to store non-pending and non-empty account ID transactions on YNAB format. const txToSend: ynab.SaveTransaction[] = []; + const missingAccounts = new Set(); for (let tx of txns) { if (tx.status === TransactionStatuses.Pending) { @@ -49,13 +49,16 @@ export class YNABStorage implements TransactionStorage { continue; } - // Converting to YNAB format. - const ynabTx = this.convertTransactionToYnabFormat(tx); - if (!ynabTx.account_id) { + const accountId = this.accountToYnabAccount.get(tx.account); + if (!accountId) { + missingAccounts.add(tx.account); stats.skipped++; continue; } + // Converting to YNAB format. + const ynabTx = this.convertTransactionToYnabFormat(tx, accountId); + // Add non-pending and non-empty account ID transactions to the array. txToSend.push(ynabTx); } @@ -70,12 +73,8 @@ export class YNABStorage implements TransactionStorage { ); logger("transactions sent to YNAB successfully!"); - if (this.missingAccounts.size > 0) { - logger( - `Accounts missing in YNAB_ACCOUNTS:`, - this.missingAccounts, - ); - this.missingAccounts.clear(); + if (missingAccounts.size > 0) { + logger(`Accounts missing in YNAB_ACCOUNTS:`, missingAccounts); } stats.added = resp.data.transactions?.length ?? 0; @@ -107,15 +106,12 @@ export class YNABStorage implements TransactionStorage { private convertTransactionToYnabFormat( tx: TransactionRow, + accountId: string, ): ynab.SaveTransaction { const amount = Math.round(tx.chargedAmount * 1000); - const accountId = this.accountToYnabAccount.get(tx.account); - if (!accountId) { - this.missingAccounts.add(tx.account); - } return { - account_id: accountId ?? "", + account_id: accountId, date: format(parseISO(tx.date), YNAB_DATE_FORMAT, {}), amount, payee_id: null,