Skip to content

Commit

Permalink
Port YNAB5 importer to TS
Browse files Browse the repository at this point in the history
  • Loading branch information
j-f1 committed Jun 27, 2023
1 parent ad48e17 commit d8d5fb9
Show file tree
Hide file tree
Showing 4 changed files with 128 additions and 57 deletions.
1 change: 1 addition & 0 deletions packages/loot-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"@types/jest": "^27.5.0",
"@types/jlongster__sql.js": "npm:@types/sql.js@latest",
"@types/pegjs": "^0.10.3",
"@types/uuid": "^9.0.2",
"@types/webpack": "^5.28.0",
"@types/webpack-bundle-analyzer": "^4.6.0",
"adm-zip": "^0.5.9",
Expand Down
74 changes: 74 additions & 0 deletions packages/loot-core/src/server/importers/ynab5-types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/* eslint-disable import/no-unused-modules */

export namespace YNAB5 {
export interface Budget {
budget_name: string;
accounts: Account[];
payees: Payee[];
category_groups: CategoryGroup[];
categories: Category[];
transactions: Transaction[];
subtransactions: Subtransaction[];
months: Month[];
}

interface Account {
id: string;
name: string;
on_budget: boolean;
deleted: boolean;
closed: boolean;
}

interface Payee {
id: string;
name: string;
deleted: boolean;
}

interface CategoryGroup {
id: string;
name: string;
deleted: boolean;
}

interface Category {
id: string;
category_group_id: string;
name: string;
deleted: boolean;
}

interface Transaction {
id: string;
account_id: string;
date: string;
payee_id: string;
import_id: string;
category_id: string;
transfer_account_id: string;
transfer_transaction_id: string;
memo: string;
cleared: string;
amount: number;
deleted: boolean;
}

interface Subtransaction {
transaction_id: string;
category_id: string;
memo: string;
amount: number;
}

interface Month {
month: string;
categories: MonthCategory[];
}

interface MonthCategory {
category_group_id: string;
id: string;
budgeted: number;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,40 +5,18 @@
import * as actual from '@actual-app/api/methods';
import uuid from 'uuid';

function amountFromYnab(amount) {
import * as monthUtils from '../../shared/months';
import { sortByKey, groupBy } from '../../shared/util';

import { YNAB5 } from './ynab5-types';

function amountFromYnab(amount: number) {
// ynabs multiplies amount by 1000 and actual by 100
// so, this function divides by 10
return Math.round(amount / 10);
}

function monthFromDate(date) {
let parts = date.split('-');
return parts[0] + '-' + parts[1];
}

function sortByKey(arr, key) {
return [...arr].sort((item1, item2) => {
if (item1[key] < item2[key]) {
return -1;
} else if (item1[key] > item2[key]) {
return 1;
}
return 0;
});
}

function groupBy(arr, keyName) {
return arr.reduce(function (obj, item) {
var key = item[keyName];
if (!obj.hasOwnProperty(key)) {
obj[key] = [];
}
obj[key].push(item);
return obj;
}, {});
}

function importAccounts(data, entityIdMap) {
function importAccounts(data: YNAB5.Budget, entityIdMap: Map<string, string>) {
return Promise.all(
data.accounts.map(async account => {
if (!account.deleted) {
Expand All @@ -53,7 +31,10 @@ function importAccounts(data, entityIdMap) {
);
}

async function importCategories(data, entityIdMap) {
async function importCategories(
data: YNAB5.Budget,
entityIdMap: Map<string, string>,
) {
// Hidden categories are put in its own group by YNAB,
// so it's already handled.

Expand Down Expand Up @@ -86,12 +67,13 @@ async function importCategories(data, entityIdMap) {

for (let group of data.category_groups) {
if (!group.deleted) {
let groupId;
// Ignores internal category and credit cards
if (
group.name !== 'Internal Master Category' &&
group.name !== 'Credit Card Payments'
) {
var groupId = await actual.createCategoryGroup({
groupId = await actual.createCategoryGroup({
name: group.name,
is_income: false,
});
Expand All @@ -104,9 +86,6 @@ async function importCategories(data, entityIdMap) {

for (let cat of cats.reverse()) {
if (!cat.deleted) {
let newCategory = {};
newCategory.name = cat.name;

// Handles special categories. Starting balance is a payee
// in YNAB so it's handled in importTransactions
switch (checkSpecialCat(cat)) {
Expand All @@ -120,8 +99,10 @@ async function importCategories(data, entityIdMap) {
case 'internal': // uncategorized is ignored too, handled by actual
break;
default: {
newCategory.group_id = groupId;
let id = await actual.createCategory(newCategory);
let id = await actual.createCategory({
name: cat.name,
group_id: groupId,
});
entityIdMap.set(cat.id, id);
break;
}
Expand All @@ -132,7 +113,7 @@ async function importCategories(data, entityIdMap) {
}
}

function importPayees(data, entityIdMap) {
function importPayees(data: YNAB5.Budget, entityIdMap: Map<string, string>) {
return Promise.all(
data.payees.map(async payee => {
if (!payee.deleted) {
Expand All @@ -145,7 +126,10 @@ function importPayees(data, entityIdMap) {
);
}

async function importTransactions(data, entityIdMap) {
async function importTransactions(
data: YNAB5.Budget,
entityIdMap: Map<string, string>,
) {
const payees = await actual.getPayees();
const categories = await actual.getCategories();
const incomeCatId = categories.find(cat => cat.name === 'Income').id;
Expand All @@ -166,8 +150,8 @@ async function importTransactions(data, entityIdMap) {
}

await Promise.all(
Object.keys(transactionsGrouped).map(async accountId => {
let transactions = transactionsGrouped[accountId];
[...transactionsGrouped.keys()].map(async accountId => {
let transactions = transactionsGrouped.get(accountId);

let toImport = transactions
.map(transaction => {
Expand All @@ -176,16 +160,7 @@ async function importTransactions(data, entityIdMap) {
}

// Handle subtransactions
let subtransactions = subtransactionsGrouped[transaction.id];
if (subtransactions) {
subtransactions = subtransactions.map(subtrans => {
return {
amount: amountFromYnab(subtrans.amount),
category: entityIdMap.get(subtrans.category_id) || null,
notes: subtrans.memo,
};
});
}
let subtransactions = subtransactionsGrouped.get(transaction.id);

// Add transaction
let newTransaction = {
Expand All @@ -199,7 +174,17 @@ async function importTransactions(data, entityIdMap) {
imported_id: transaction.import_id || null,
transfer_id:
entityIdMap.get(transaction.transfer_transaction_id) || null,
subtransactions: subtransactions,
subtransactions: subtransactions
? subtransactions.map(subtrans => {
return {
amount: amountFromYnab(subtrans.amount),
category: entityIdMap.get(subtrans.category_id) || null,
notes: subtrans.memo,
};
})
: null,
payee: null,
imported_payee: null,
};

// Handle transfer payee
Expand Down Expand Up @@ -233,7 +218,10 @@ async function importTransactions(data, entityIdMap) {
);
}

async function importBudgets(data, entityIdMap) {
async function importBudgets(
data: YNAB5.Budget,
entityIdMap: Map<string, string>,
) {
// There should be info in the docs to deal with
// no credit card category and how YNAB and Actual
// handle differently the amount To be Budgeted
Expand All @@ -253,7 +241,7 @@ async function importBudgets(data, entityIdMap) {

await actual.batchBudgetUpdates(async () => {
for (let budget of budgets) {
let month = monthFromDate(budget.month);
let month = monthUtils.monthFromDate(budget.month);

await Promise.all(
budget.categories.map(async catBudget => {
Expand All @@ -277,8 +265,8 @@ async function importBudgets(data, entityIdMap) {

// Utils

export async function doImport(data) {
const entityIdMap = new Map();
export async function doImport(data: YNAB5.Budget) {
const entityIdMap = new Map<string, string>();

console.log('Importing Accounts...');
await importAccounts(data, entityIdMap);
Expand All @@ -298,7 +286,7 @@ export async function doImport(data) {
console.log('Setting up...');
}

export function parseFile(buffer) {
export function parseFile(buffer: Buffer): YNAB5.Budget {
let data = JSON.parse(buffer.toString());
if (data.data) {
data = data.data;
Expand All @@ -307,6 +295,6 @@ export function parseFile(buffer) {
return data;
}

export function getBudgetName(_filepath, data) {
export function getBudgetName(_filepath: string, data: YNAB5.Budget) {
return data.budget_name;
}
8 changes: 8 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4450,6 +4450,13 @@ __metadata:
languageName: node
linkType: hard

"@types/uuid@npm:^9.0.2":
version: 9.0.2
resolution: "@types/uuid@npm:9.0.2"
checksum: 1754bcf3444e1e3aeadd6e774fc328eb53bc956665e2e8fb6ec127aa8e1f43d9a224c3d22a9a6233dca8dd81a12dc7fed4d84b8876dd5ec82d40f574f7ff8b68
languageName: node
linkType: hard

"@types/verror@npm:^1.10.3":
version: 1.10.6
resolution: "@types/verror@npm:1.10.6"
Expand Down Expand Up @@ -12316,6 +12323,7 @@ __metadata:
"@types/jest": ^27.5.0
"@types/jlongster__sql.js": "npm:@types/sql.js@latest"
"@types/pegjs": ^0.10.3
"@types/uuid": ^9.0.2
"@types/webpack": ^5.28.0
"@types/webpack-bundle-analyzer": ^4.6.0
absurd-sql: 0.0.53
Expand Down

0 comments on commit d8d5fb9

Please sign in to comment.