Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: ynab support #1

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -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"
}
Expand Down
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"
}
```
47 changes: 46 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@
"dotenv": "^16.4.1",
"google-auth-library": "^9.6.3",
"google-spreadsheet": "^4.1.1",
"hash-it": "^6.0.0",
"israeli-bank-scrapers": "^4.2.2",
"telegraf": "^4.15.3"
"telegraf": "^4.15.3",
"ynab": "^2.2.0"
},
"devDependencies": {
"@types/debug": "^4.1.12",
Expand Down
12 changes: 11 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ const {
WORKSHEET_NAME,
ACCOUNTS_TO_SCRAPE = "",
FUTURE_MONTHS = "",
YNAB_TOKEN = "",
YNAB_BUDGET_ID = "",
YNAB_ACCOUNTS = "",
} = process.env;

/**
Expand All @@ -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));
Expand Down
2 changes: 2 additions & 0 deletions src/storage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
128 changes: 128 additions & 0 deletions src/storage/ynab.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
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";
import { TransactionStatuses } from "israeli-bank-scrapers/lib/transactions.js";

const YNAB_DATE_FORMAT = "yyyy-MM-dd";
const logger = createLogger("YNABStorage");

export class YNABStorage implements TransactionStorage {
private ynabAPI: ynab.API;
private budgetName: string;
private accountToYnabAccount: Map<string, string>;

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() {
return Boolean(YNAB_TOKEN && YNAB_BUDGET_ID);
}

async saveTransactions(txns: Array<TransactionRow>) {
await this.init();

const stats = {
name: "YNABStorage",
table: `budget: "${this.budgetName}"`,
total: txns.length,
added: 0,
pending: 0,
existing: 0,
skipped: 0,
} satisfies SaveStats;

// Initialize an array to store non-pending and non-empty account ID transactions on YNAB format.
const txToSend: ynab.SaveTransaction[] = [];
const missingAccounts = new Set<string>();

for (let tx of txns) {
if (tx.status === TransactionStatuses.Pending) {
stats.pending++;
stats.skipped++;
continue;
}

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);
}

// Send transactions to YNAB
logger(`sending to YNAB budget: "${this.budgetName}"`);
const resp = await this.ynabAPI.transactions.createTransactions(
YNAB_BUDGET_ID,
{
transactions: txToSend,
},
);
logger("transactions sent to YNAB successfully!");

if (missingAccounts.size > 0) {
logger(`Accounts missing in YNAB_ACCOUNTS:`, missingAccounts);
}

stats.added = resp.data.transactions?.length ?? 0;
stats.existing = resp.data.duplicate_import_ids?.length ?? 0;
stats.skipped += stats.existing;

return stats;
}

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}`);
}
}

private parseYnabAccounts(accountsJSON: string): Map<string, string> {
try {
const accounts = JSON.parse(accountsJSON);
return new Map(Object.entries(accounts));
} catch (parseError) {
throw new Error(
`Error parsing JSON in YNAB_ACCOUNTS: ${parseError.message}`,
);
}
}

private convertTransactionToYnabFormat(
tx: TransactionRow,
accountId: string,
): ynab.SaveTransaction {
const amount = Math.round(tx.chargedAmount * 1000);

return {
account_id: accountId,
date: format(parseISO(tx.date), YNAB_DATE_FORMAT, {}),
amount,
payee_id: null,
payee_name: tx.description,
cleared:
tx.status === TransactionStatuses.Completed
? ynab.TransactionClearedStatus.Cleared
: undefined,
approved: false,
import_id: hash(tx.hash).toString(),
memo: tx.memo,
};
}
}