├── .gitignore ├── importer.ios.js ├── importer.web.js ├── index.js ├── README.md ├── package.json ├── yarn.lock └── importer.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /importer.ios.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | findBudgetsInDir: () => [], 3 | findBudgets: () => [], 4 | importYNAB4: () => {} 5 | }; 6 | -------------------------------------------------------------------------------- /importer.web.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | findBudgetsInDir: () => [], 3 | findBudgets: () => [], 4 | importYNAB4: () => {} 5 | }; 6 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const { findBudgets, importYNAB4 } = require('./importer'); 3 | 4 | async function run() { 5 | let filepath = process.argv[2]; 6 | await importYNAB4(filepath); 7 | } 8 | 9 | run(); 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **This repository is archived.** 2 | 3 | New location for the package: https://github.com/actualbudget/actual/tree/master/packages/import-ynab4 4 | 5 | --- 6 | 7 | ``` 8 | npx @actual-app/import-ynab4 9 | ``` 10 | 11 | This uses Actual's [API](https://github.com/actualbudget/node-api) to import data from YNAB4. 12 | 13 | Docs for usage: https://actualbudget.com/docs/overview/migrating-from-other-apps/ 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@actual-app/import-ynab4", 3 | "description": "A tool for importing YNAB4 data into Actual", 4 | "version": "1.0.8", 5 | "main": "index.js", 6 | "devDependencies": {}, 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/actualbudget/importer-ynab4.git" 10 | }, 11 | "author": "James Long", 12 | "license": "ISC", 13 | "bugs": { 14 | "url": "https://github.com/actualbudget/importer-ynab4/issues" 15 | }, 16 | "bin": "./index.js", 17 | "homepage": "https://github.com/actualbudget/importer-ynab4#readme", 18 | "dependencies": { 19 | "@actual-app/api": "^1.0.0", 20 | "date-fns": "2.0.0-alpha.27", 21 | "slash": "3.0.0", 22 | "uuid": "3.3.2" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@actual-app/api@^1.0.0": 6 | version "1.0.1" 7 | resolved "https://registry.yarnpkg.com/@actual-app/api/-/api-1.0.1.tgz#b13c800bd832ddcee01f87790f21b4f8b96623af" 8 | integrity sha512-V8CxFqzcq/uIznrDHJIQ9Xpr+/bNHfgBrwi6uQQic0foafY2QUXIy/m0VMimxzUAjWhMOouRDWSV8CAwanz4zA== 9 | dependencies: 10 | node-ipc "9.1.1" 11 | uuid "3.3.2" 12 | 13 | date-fns@2.0.0-alpha.27: 14 | version "2.0.0-alpha.27" 15 | resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.0.0-alpha.27.tgz#5ecd4204ef0e7064264039570f6e8afbc014481c" 16 | integrity sha512-cqfVLS+346P/Mpj2RpDrBv0P4p2zZhWWvfY5fuWrXNR/K38HaAGEkeOwb47hIpQP9Jr/TIxjZ2/sNMQwdXuGMg== 17 | 18 | easy-stack@^1.0.0: 19 | version "1.0.0" 20 | resolved "https://registry.yarnpkg.com/easy-stack/-/easy-stack-1.0.0.tgz#12c91b3085a37f0baa336e9486eac4bf94e3e788" 21 | integrity sha1-EskbMIWjfwuqM26UhurEv5Tj54g= 22 | 23 | event-pubsub@4.3.0: 24 | version "4.3.0" 25 | resolved "https://registry.yarnpkg.com/event-pubsub/-/event-pubsub-4.3.0.tgz#f68d816bc29f1ec02c539dc58c8dd40ce72cb36e" 26 | integrity sha512-z7IyloorXvKbFx9Bpie2+vMJKKx1fH1EN5yiTfp8CiLOTptSYy1g8H4yDpGlEdshL1PBiFtBHepF2cNsqeEeFQ== 27 | 28 | js-message@1.0.5: 29 | version "1.0.5" 30 | resolved "https://registry.yarnpkg.com/js-message/-/js-message-1.0.5.tgz#2300d24b1af08e89dd095bc1a4c9c9cfcb892d15" 31 | integrity sha1-IwDSSxrwjondCVvBpMnJz8uJLRU= 32 | 33 | js-queue@2.0.0: 34 | version "2.0.0" 35 | resolved "https://registry.yarnpkg.com/js-queue/-/js-queue-2.0.0.tgz#362213cf860f468f0125fc6c96abc1742531f948" 36 | integrity sha1-NiITz4YPRo8BJfxslqvBdCUx+Ug= 37 | dependencies: 38 | easy-stack "^1.0.0" 39 | 40 | node-ipc@9.1.1: 41 | version "9.1.1" 42 | resolved "https://registry.yarnpkg.com/node-ipc/-/node-ipc-9.1.1.tgz#4e245ed6938e65100e595ebc5dc34b16e8dd5d69" 43 | integrity sha512-FAyICv0sIRJxVp3GW5fzgaf9jwwRQxAKDJlmNFUL5hOy+W4X/I5AypyHoq0DXXbo9o/gt79gj++4cMr4jVWE/w== 44 | dependencies: 45 | event-pubsub "4.3.0" 46 | js-message "1.0.5" 47 | js-queue "2.0.0" 48 | 49 | slash@3.0.0: 50 | version "3.0.0" 51 | resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" 52 | integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== 53 | 54 | uuid@3.3.2: 55 | version "3.3.2" 56 | resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131" 57 | integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA== 58 | -------------------------------------------------------------------------------- /importer.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const os = require('os'); 3 | const { join } = require('path'); 4 | const d = require('date-fns'); 5 | const normalizePathSep = require('slash'); 6 | const uuid = require('uuid'); 7 | const actual = require('@actual-app/api'); 8 | const { amountToInteger } = actual.utils; 9 | 10 | // Utils 11 | 12 | function mapAccountType(type) { 13 | switch (type) { 14 | case 'Cash': 15 | case 'Checking': 16 | return 'checking'; 17 | case 'CreditCard': 18 | return 'credit'; 19 | case 'Savings': 20 | return 'savings'; 21 | case 'InvestmentAccount': 22 | return 'investment'; 23 | case 'Mortgage': 24 | return 'mortgage'; 25 | default: 26 | return 'other'; 27 | } 28 | } 29 | 30 | function sortByKey(arr, key) { 31 | return [...arr].sort((item1, item2) => { 32 | if (item1[key] < item2[key]) { 33 | return -1; 34 | } else if (item1[key] > item2[key]) { 35 | return 1; 36 | } 37 | return 0; 38 | }); 39 | } 40 | 41 | function groupBy(arr, keyName) { 42 | return arr.reduce(function(obj, item) { 43 | var key = item[keyName]; 44 | if (!obj.hasOwnProperty(key)) { 45 | obj[key] = []; 46 | } 47 | obj[key].push(item); 48 | return obj; 49 | }, {}); 50 | } 51 | 52 | function _parse(value) { 53 | if (typeof value === 'string') { 54 | // We don't want parsing to take local timezone into account, 55 | // which parsing a string does. Pass the integers manually to 56 | // bypass it. 57 | 58 | let [year, month, day] = value.split('-'); 59 | if (day != null) { 60 | return new Date(parseInt(year), parseInt(month) - 1, parseInt(day)); 61 | } else if (month != null) { 62 | return new Date(parseInt(year), parseInt(month) - 1, 1); 63 | } else { 64 | return new Date(parseInt(year), 0, 1); 65 | } 66 | } 67 | return value; 68 | } 69 | 70 | function monthFromDate(date) { 71 | return d.format(_parse(date), 'yyyy-MM'); 72 | } 73 | 74 | function getCurrentMonth() { 75 | return d.format(new Date(), 'yyyy-MM'); 76 | } 77 | 78 | // Importer 79 | 80 | async function importAccounts(data, entityIdMap) { 81 | return Promise.all( 82 | data.accounts.map(async account => { 83 | if (!account.isTombstone) { 84 | const id = await actual.createAccount({ 85 | type: mapAccountType(account.accountType), 86 | name: account.accountName, 87 | offbudget: account.onBudget ? false : true, 88 | closed: account.hidden ? true : false 89 | }); 90 | entityIdMap.set(account.entityId, id); 91 | } 92 | }) 93 | ); 94 | } 95 | 96 | async function importCategories(data, entityIdMap) { 97 | const masterCategories = sortByKey(data.masterCategories, 'sortableIndex'); 98 | 99 | await Promise.all( 100 | masterCategories.map(async masterCategory => { 101 | if ( 102 | masterCategory.type === 'OUTFLOW' && 103 | !masterCategory.isTombstone && 104 | masterCategory.subCategories && 105 | masterCategory.subCategories.some(cat => !cat.isTombstone) > 0 106 | ) { 107 | const id = await actual.createCategoryGroup({ 108 | name: masterCategory.name, 109 | is_income: false 110 | }); 111 | entityIdMap.set(masterCategory.entityId, id); 112 | 113 | if (masterCategory.subCategories) { 114 | const subCategories = sortByKey( 115 | masterCategory.subCategories, 116 | 'sortableIndex' 117 | ); 118 | subCategories.reverse(); 119 | 120 | // This can't be done in parallel because sort order depends 121 | // on insertion order 122 | for (let category of subCategories) { 123 | if (!category.isTombstone) { 124 | const id = await actual.createCategory({ 125 | name: category.name, 126 | group_id: entityIdMap.get(category.masterCategoryId) 127 | }); 128 | entityIdMap.set(category.entityId, id); 129 | } 130 | } 131 | } 132 | } 133 | }) 134 | ); 135 | } 136 | 137 | async function importPayees(data, entityIdMap) { 138 | for (let payee of data.payees) { 139 | if (!payee.isTombstone) { 140 | let id = await actual.createPayee({ 141 | name: payee.name, 142 | category: entityIdMap.get(payee.autoFillCategoryId) || null, 143 | transfer_acct: entityIdMap.get(payee.targetAccountId) || null 144 | }); 145 | 146 | // TODO: import payee rules 147 | 148 | entityIdMap.set(payee.entityId, id); 149 | } 150 | } 151 | } 152 | 153 | async function importTransactions(data, entityIdMap) { 154 | const categories = await actual.getCategories(); 155 | const incomeCategoryId = categories.find(cat => cat.name === 'Income').id; 156 | const accounts = await actual.getAccounts(); 157 | const payees = await actual.getPayees(); 158 | 159 | function getCategory(id) { 160 | if (id == null || id === 'Category/__Split__') { 161 | return null; 162 | } else if ( 163 | id === 'Category/__ImmediateIncome__' || 164 | id === 'Category/__DeferredIncome__' 165 | ) { 166 | return incomeCategoryId; 167 | } 168 | return entityIdMap.get(id); 169 | } 170 | 171 | function isOffBudget(acctId) { 172 | let acct = accounts.find(acct => acct.id === acctId); 173 | if (!acct) { 174 | throw new Error('Could not find account for transaction when importing'); 175 | } 176 | return acct.offbudget; 177 | } 178 | 179 | // Go ahead and generate ids for all of the transactions so we can 180 | // reliably resolve transfers 181 | for (let transaction of data.transactions) { 182 | entityIdMap.set(transaction.entityId, uuid.v4()); 183 | } 184 | 185 | let sortOrder = 1; 186 | let transactionsGrouped = groupBy(data.transactions, 'accountId'); 187 | 188 | await Promise.all( 189 | Object.keys(transactionsGrouped).map(async accountId => { 190 | let transactions = transactionsGrouped[accountId]; 191 | 192 | let toImport = transactions 193 | .map(transaction => { 194 | if (transaction.isTombstone) { 195 | return; 196 | } 197 | 198 | let id = entityIdMap.get(transaction.entityId); 199 | let transferId = 200 | entityIdMap.get(transaction.transferTransactionId) || null; 201 | 202 | let payee_id = null; 203 | let payee = null; 204 | if (transferId) { 205 | payee_id = payees.find( 206 | p => 207 | p.transfer_acct === entityIdMap.get(transaction.targetAccountId) 208 | ).id; 209 | } else { 210 | payee_id = entityIdMap.get(transaction.payeeId); 211 | } 212 | 213 | let newTransaction = { 214 | id, 215 | amount: amountToInteger(transaction.amount), 216 | category_id: isOffBudget(entityIdMap.get(accountId)) 217 | ? null 218 | : getCategory(transaction.categoryId), 219 | date: transaction.date, 220 | notes: transaction.memo || null, 221 | payee, 222 | payee_id, 223 | transfer_id: transferId 224 | }; 225 | 226 | newTransaction.subtransactions = 227 | transaction.subTransactions && 228 | transaction.subTransactions.map((t, i) => { 229 | return { 230 | amount: amountToInteger(t.amount), 231 | category_id: getCategory(t.categoryId) 232 | }; 233 | }); 234 | 235 | return newTransaction; 236 | }) 237 | .filter(x => x); 238 | 239 | await actual.addTransactions(entityIdMap.get(accountId), toImport); 240 | }) 241 | ); 242 | } 243 | 244 | function fillInBudgets(data, categoryBudgets) { 245 | // YNAB only contains entries for categories that have been actually 246 | // budgeted. That would be fine except that we need to set the 247 | // "carryover" flag on each month when carrying debt across months. 248 | // To make sure our system has a chance to set this flag on each 249 | // category, make sure a budget exists for every category of every 250 | // month. 251 | const budgets = [...categoryBudgets]; 252 | data.masterCategories.forEach(masterCategory => { 253 | if (masterCategory.subCategories) { 254 | masterCategory.subCategories.forEach(category => { 255 | if (!budgets.find(b => b.categoryId === category.entityId)) { 256 | budgets.push({ 257 | budgeted: 0, 258 | categoryId: category.entityId 259 | }); 260 | } 261 | }); 262 | } 263 | }); 264 | return budgets; 265 | } 266 | 267 | async function importBudgets(data, entityIdMap) { 268 | let budgets = sortByKey(data.monthlyBudgets, 'month'); 269 | let earliestMonth = monthFromDate(budgets[0].month); 270 | let currentMonth = getCurrentMonth(); 271 | 272 | await actual.batchBudgetUpdates(async () => { 273 | const carryoverFlags = {}; 274 | 275 | for (let budget of budgets) { 276 | let filled = fillInBudgets( 277 | data, 278 | budget.monthlySubCategoryBudgets.filter(b => !b.isTombstone) 279 | ); 280 | 281 | await Promise.all( 282 | filled.map(async catBudget => { 283 | let amount = amountToInteger(catBudget.budgeted); 284 | let catId = entityIdMap.get(catBudget.categoryId); 285 | let month = monthFromDate(budget.month); 286 | if (!catId) { 287 | return; 288 | } 289 | 290 | await actual.setBudgetAmount(month, catId, amount); 291 | 292 | if (catBudget.overspendingHandling === 'AffectsBuffer') { 293 | // Turn off the carryover flag so it doesn't propagate 294 | // to future months 295 | carryoverFlags[catId] = false; 296 | } else if ( 297 | catBudget.overspendingHandling === 'Confined' || 298 | carryoverFlags[catId] 299 | ) { 300 | // Overspending has switched to carryover, set the 301 | // flag so it propagates to future months 302 | carryoverFlags[catId] = true; 303 | 304 | await actual.setBudgetCarryover(month, catId, true); 305 | } 306 | }) 307 | ); 308 | } 309 | }); 310 | } 311 | 312 | function estimateRecentness(str) { 313 | // The "recentness" is the total amount of changes that this device 314 | // is aware of, which is estimated by summing up all of the version 315 | // numbers that its aware of. This works because version numbers are 316 | // increasing integers. 317 | return str.split(',').reduce((total, version) => { 318 | const [_, number] = version.split('-'); 319 | return total + parseInt(number); 320 | }, 0); 321 | } 322 | 323 | function findLatestDevice(files) { 324 | let devices = files 325 | .map(deviceFile => { 326 | const contents = fs.readFileSync(deviceFile, 'utf8'); 327 | 328 | let data; 329 | try { 330 | data = JSON.parse(contents); 331 | } catch (e) { 332 | return null; 333 | } 334 | 335 | if (data.hasFullKnowledge) { 336 | return { 337 | deviceGUID: data.deviceGUID, 338 | shortName: data.shortDeviceId, 339 | recentness: estimateRecentness(data.knowledge) 340 | }; 341 | } 342 | 343 | return null; 344 | }) 345 | .filter(x => x); 346 | 347 | devices = sortByKey(devices, 'recentness'); 348 | return devices[devices.length - 1].deviceGUID; 349 | } 350 | 351 | async function doImport(data) { 352 | const entityIdMap = new Map(); 353 | 354 | console.log('Importing Accounts...'); 355 | await importAccounts(data, entityIdMap); 356 | 357 | console.log('Importing Categories...'); 358 | await importCategories(data, entityIdMap); 359 | 360 | console.log('Importing Payees...'); 361 | await importPayees(data, entityIdMap); 362 | 363 | console.log('Importing Transactions...'); 364 | await importTransactions(data, entityIdMap); 365 | 366 | console.log('Importing Budgets...'); 367 | await importBudgets(data, entityIdMap); 368 | 369 | console.log('Setting up...'); 370 | } 371 | 372 | function getBudgetName(filepath) { 373 | let unixFilepath = normalizePathSep(filepath); 374 | 375 | // Most budgets are named like "Budget~51938D82.ynab4" but sometimes 376 | // they are only "Budget.ynab4". We only want to grab the name 377 | // before the ~ if it exists. 378 | let m = unixFilepath.match(/([^\/\~]*)\~.*\.ynab4$/); 379 | if (!m) { 380 | m = unixFilepath.match(/([^\/]*)\.ynab4$/); 381 | } 382 | if (!m) { 383 | return null; 384 | } 385 | return m[1]; 386 | } 387 | 388 | async function importYNAB4(filepath) { 389 | const budgetName = getBudgetName(filepath); 390 | 391 | if (!budgetName) { 392 | throw new Error('Not a YNAB4 file: ' + filepath); 393 | } 394 | 395 | const metaStr = fs.readFileSync(join(filepath, 'Budget.ymeta')); 396 | const meta = JSON.parse(metaStr); 397 | const budgetPath = join(filepath, meta.relativeDataFolderName); 398 | 399 | const deviceFiles = fs.readdirSync(join(budgetPath, 'devices')); 400 | let deviceGUID = findLatestDevice( 401 | deviceFiles.map(f => join(budgetPath, 'devices', f)) 402 | ); 403 | 404 | const yfullPath = join(budgetPath, deviceGUID, 'Budget.yfull'); 405 | let contents; 406 | try { 407 | contents = fs.readFileSync(yfullPath, 'utf8'); 408 | } catch (e) { 409 | throw new Error('Error reading Budget.yfull file'); 410 | } 411 | 412 | let data; 413 | try { 414 | data = JSON.parse(contents); 415 | } catch (e) { 416 | throw new Error('Error parsing Budget.yull file'); 417 | } 418 | 419 | return actual.runImport(budgetName, () => doImport(data)); 420 | } 421 | 422 | function findBudgetsInDir(dir) { 423 | if (fs.existsSync(dir)) { 424 | return fs 425 | .readdirSync(dir) 426 | .map(file => { 427 | const name = getBudgetName(file); 428 | if (name) { 429 | return { 430 | name, 431 | filepath: join(dir, file) 432 | }; 433 | } 434 | }) 435 | .filter(x => x); 436 | } 437 | return []; 438 | } 439 | 440 | function findBudgets() { 441 | return findBudgetsInDir(join(os.homedir(), 'Documents', 'YNAB')).concat( 442 | findBudgetsInDir(join(os.homedir(), 'Dropbox', 'YNAB')) 443 | ); 444 | } 445 | 446 | module.exports = { findBudgetsInDir, findBudgets, importYNAB4 }; 447 | --------------------------------------------------------------------------------