@actual-app/core 26.3.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.
Files changed (358) hide show
  1. package/.swcrc +11 -0
  2. package/bin/build-browser +40 -0
  3. package/bin/copy-migrations +9 -0
  4. package/db.sqlite +0 -0
  5. package/default-db.sqlite +0 -0
  6. package/migrations/.force-copy-windows +0 -0
  7. package/migrations/1548957970627_remove-db-version.sql +5 -0
  8. package/migrations/1550601598648_payees.sql +23 -0
  9. package/migrations/1555786194328_remove_category_group_unique.sql +25 -0
  10. package/migrations/1561751833510_indexes.sql +7 -0
  11. package/migrations/1567699552727_budget.sql +38 -0
  12. package/migrations/1582384163573_cleared.sql +6 -0
  13. package/migrations/1597756566448_rules.sql +10 -0
  14. package/migrations/1608652596043_parent_field.sql +13 -0
  15. package/migrations/1608652596044_trans_views.sql +56 -0
  16. package/migrations/1612625548236_optimize.sql +7 -0
  17. package/migrations/1614782639336_trans_views2.sql +33 -0
  18. package/migrations/1615745967948_meta.sql +10 -0
  19. package/migrations/1616167010796_accounts_order.sql +5 -0
  20. package/migrations/1618975177358_schedules.sql +28 -0
  21. package/migrations/1632571489012_remove_cache.js +136 -0
  22. package/migrations/1679728867040_rules_conditions.sql +5 -0
  23. package/migrations/1681115033845_add_schedule_name.sql +5 -0
  24. package/migrations/1682974838138_remove_payee_rules.sql +5 -0
  25. package/migrations/1685007876842_add_category_hidden.sql +6 -0
  26. package/migrations/1686139660866_remove_account_type.sql +5 -0
  27. package/migrations/1688749527273_transaction_filters.sql +10 -0
  28. package/migrations/1688841238000_add_account_type.sql +5 -0
  29. package/migrations/1691233396000_add_schedule_next_date_tombstone.sql +5 -0
  30. package/migrations/1694438752000_add_goal_targets.sql +7 -0
  31. package/migrations/1697046240000_add_reconciled.sql +5 -0
  32. package/migrations/1704572023730_add_account_sync_source.sql +5 -0
  33. package/migrations/1704572023731_add_missing_goCardless_sync_source.sql +9 -0
  34. package/migrations/1707267033000_reports.sql +28 -0
  35. package/migrations/1712784523000_unhide_input_group.sql +8 -0
  36. package/migrations/1716359441000_include_current.sql +5 -0
  37. package/migrations/1720310586000_link_transfer_schedules.sql +19 -0
  38. package/migrations/1720664867241_add_payee_favorite.sql +5 -0
  39. package/migrations/1720665000000_goal_context.sql +6 -0
  40. package/migrations/1722717601000_reports_move_selected_categories.js +55 -0
  41. package/migrations/1722804019000_create_dashboard_table.js +69 -0
  42. package/migrations/1723665565000_prefs.js +59 -0
  43. package/migrations/1730744182000_fix_dashboard_table.sql +7 -0
  44. package/migrations/1736640000000_custom_report_sorting.sql +7 -0
  45. package/migrations/1737158400000_add_learn_categories_to_payees.sql +5 -0
  46. package/migrations/1738491452000_sorting_rename.sql +13 -0
  47. package/migrations/1739139550000_bank_sync_page.sql +7 -0
  48. package/migrations/1740506588539_add_last_reconciled_at.sql +5 -0
  49. package/migrations/1745425408000_update_budgetType_pref.sql +7 -0
  50. package/migrations/1749799110000_add_tags.sql +10 -0
  51. package/migrations/1749799110001_tags_tombstone.sql +5 -0
  52. package/migrations/1754611200000_add_category_template_settings.sql +5 -0
  53. package/migrations/1759260219000_add_trim_interval_report_setting.sql +6 -0
  54. package/migrations/1759842823172_add_isGlobal_to_preferences.sql +1 -0
  55. package/migrations/1762178745667_rename_csv_skip_lines_pref.sql +8 -0
  56. package/migrations/1765518577215_multiple_dashboards.js +30 -0
  57. package/migrations/1768872504000_add_payee_locations.sql +21 -0
  58. package/package.json +128 -0
  59. package/src/mocks/arbitrary-schema.ts +162 -0
  60. package/src/mocks/budget.ts +901 -0
  61. package/src/mocks/files/8859-1.qfx +63 -0
  62. package/src/mocks/files/best.data-ever$.QFX +124 -0
  63. package/src/mocks/files/big.data.QiF +91 -0
  64. package/src/mocks/files/budgets/.commit-to-git +0 -0
  65. package/src/mocks/files/camt/camt.053.payee-memo.xml +127 -0
  66. package/src/mocks/files/camt/camt.053.xml +463 -0
  67. package/src/mocks/files/credit-card.ofx +11 -0
  68. package/src/mocks/files/data-multi-decimal.ofx +64 -0
  69. package/src/mocks/files/data-payee-memo.ofx +75 -0
  70. package/src/mocks/files/data-payee-memo.qif +17 -0
  71. package/src/mocks/files/data.ofx +124 -0
  72. package/src/mocks/files/data.qfx +124 -0
  73. package/src/mocks/files/data.qif +91 -0
  74. package/src/mocks/files/default-budget-template/db.sqlite +0 -0
  75. package/src/mocks/files/default-budget-template/metadata.json +6 -0
  76. package/src/mocks/files/html-vals.qfx +17 -0
  77. package/src/mocks/index.ts +221 -0
  78. package/src/mocks/migrations/1508717984291_up_add-poop.sql +13 -0
  79. package/src/mocks/migrations/1508718036311_up_modify-poop.sql +2 -0
  80. package/src/mocks/migrations/1508727787513_remove-is_income.sql +15 -0
  81. package/src/mocks/random.ts +16 -0
  82. package/src/mocks/setup.ts +180 -0
  83. package/src/mocks/spreadsheet.ts +101 -0
  84. package/src/mocks/util.ts +82 -0
  85. package/src/platform/client/connection/README.md +3 -0
  86. package/src/platform/client/connection/__mocks__/index.ts +67 -0
  87. package/src/platform/client/connection/index-types.ts +95 -0
  88. package/src/platform/client/connection/index.browser.ts +213 -0
  89. package/src/platform/client/connection/index.ts +155 -0
  90. package/src/platform/client/undo/index.ts +59 -0
  91. package/src/platform/exceptions/__mocks__/index.ts +7 -0
  92. package/src/platform/exceptions/index.ts +9 -0
  93. package/src/platform/server/asyncStorage/__mocks__/index.ts +50 -0
  94. package/src/platform/server/asyncStorage/index-types.ts +35 -0
  95. package/src/platform/server/asyncStorage/index.api.ts +2 -0
  96. package/src/platform/server/asyncStorage/index.electron.ts +88 -0
  97. package/src/platform/server/asyncStorage/index.ts +126 -0
  98. package/src/platform/server/connection/README.md +3 -0
  99. package/src/platform/server/connection/__mocks__/index.ts +15 -0
  100. package/src/platform/server/connection/index-types.ts +20 -0
  101. package/src/platform/server/connection/index.api.ts +13 -0
  102. package/src/platform/server/connection/index.electron.ts +102 -0
  103. package/src/platform/server/connection/index.ts +154 -0
  104. package/src/platform/server/fetch/__mocks__/index.ts +3 -0
  105. package/src/platform/server/fetch/index.api.ts +1 -0
  106. package/src/platform/server/fetch/index.electron.ts +18 -0
  107. package/src/platform/server/fetch/index.ts +20 -0
  108. package/src/platform/server/fs/index.api.ts +198 -0
  109. package/src/platform/server/fs/index.electron.ts +208 -0
  110. package/src/platform/server/fs/index.test.ts +117 -0
  111. package/src/platform/server/fs/index.ts +416 -0
  112. package/src/platform/server/fs/path-join.api.ts +1 -0
  113. package/src/platform/server/fs/path-join.electron.ts +1 -0
  114. package/src/platform/server/fs/path-join.ts +97 -0
  115. package/src/platform/server/fs/shared.ts +33 -0
  116. package/src/platform/server/indexeddb/index.ts +115 -0
  117. package/src/platform/server/log/index.ts +43 -0
  118. package/src/platform/server/sqlite/index.api.ts +2 -0
  119. package/src/platform/server/sqlite/index.electron.ts +134 -0
  120. package/src/platform/server/sqlite/index.test.ts +108 -0
  121. package/src/platform/server/sqlite/index.ts +241 -0
  122. package/src/platform/server/sqlite/normalise.ts +9 -0
  123. package/src/platform/server/sqlite/unicodeLike.test.ts +58 -0
  124. package/src/platform/server/sqlite/unicodeLike.ts +31 -0
  125. package/src/server/__mocks__/post.ts +9 -0
  126. package/src/server/__snapshots__/main.test.ts.snap +199 -0
  127. package/src/server/__snapshots__/sheet.test.ts.snap +9 -0
  128. package/src/server/accounts/__snapshots__/sync.test.ts.snap +136 -0
  129. package/src/server/accounts/app-bank-sync.test.ts +136 -0
  130. package/src/server/accounts/app.ts +1294 -0
  131. package/src/server/accounts/link.ts +25 -0
  132. package/src/server/accounts/payees.ts +36 -0
  133. package/src/server/accounts/sync.test.ts +679 -0
  134. package/src/server/accounts/sync.ts +1168 -0
  135. package/src/server/accounts/title/index.ts +60 -0
  136. package/src/server/accounts/title/lower-case.ts +93 -0
  137. package/src/server/accounts/title/specials.ts +21 -0
  138. package/src/server/admin/app.ts +241 -0
  139. package/src/server/api-models.ts +244 -0
  140. package/src/server/api.test.ts +36 -0
  141. package/src/server/api.ts +1030 -0
  142. package/src/server/app.ts +91 -0
  143. package/src/server/aql/compiler.test.ts +966 -0
  144. package/src/server/aql/compiler.ts +1222 -0
  145. package/src/server/aql/exec.test.ts +289 -0
  146. package/src/server/aql/exec.ts +128 -0
  147. package/src/server/aql/index.ts +41 -0
  148. package/src/server/aql/schema/executors.test.ts +420 -0
  149. package/src/server/aql/schema/executors.ts +345 -0
  150. package/src/server/aql/schema/index.test.ts +67 -0
  151. package/src/server/aql/schema/index.ts +409 -0
  152. package/src/server/aql/schema-helpers.test.ts +242 -0
  153. package/src/server/aql/schema-helpers.ts +208 -0
  154. package/src/server/aql/views.test.ts +62 -0
  155. package/src/server/aql/views.ts +57 -0
  156. package/src/server/auth/app.ts +387 -0
  157. package/src/server/bench.ts +29 -0
  158. package/src/server/budget/actions.ts +686 -0
  159. package/src/server/budget/app.ts +469 -0
  160. package/src/server/budget/base.test.ts +340 -0
  161. package/src/server/budget/base.ts +339 -0
  162. package/src/server/budget/category-template-context.test.ts +1658 -0
  163. package/src/server/budget/category-template-context.ts +862 -0
  164. package/src/server/budget/cleanup-template.pegjs +27 -0
  165. package/src/server/budget/cleanup-template.ts +408 -0
  166. package/src/server/budget/envelope.ts +403 -0
  167. package/src/server/budget/goal-template.pegjs +110 -0
  168. package/src/server/budget/goal-template.ts +309 -0
  169. package/src/server/budget/report.ts +308 -0
  170. package/src/server/budget/schedule-template.test.ts +184 -0
  171. package/src/server/budget/schedule-template.ts +351 -0
  172. package/src/server/budget/statements.ts +60 -0
  173. package/src/server/budget/template-notes.test.ts +393 -0
  174. package/src/server/budget/template-notes.ts +323 -0
  175. package/src/server/budget/util.ts +25 -0
  176. package/src/server/budgetfiles/__snapshots__/backups.test.ts.snap +101 -0
  177. package/src/server/budgetfiles/app.ts +672 -0
  178. package/src/server/budgetfiles/backups.test.ts +79 -0
  179. package/src/server/budgetfiles/backups.ts +251 -0
  180. package/src/server/cloud-storage.ts +467 -0
  181. package/src/server/dashboard/app.ts +373 -0
  182. package/src/server/db/__snapshots__/index.test.ts.snap +271 -0
  183. package/src/server/db/index.test.ts +300 -0
  184. package/src/server/db/index.ts +855 -0
  185. package/src/server/db/mappings.ts +59 -0
  186. package/src/server/db/sort.ts +58 -0
  187. package/src/server/db/types/index.ts +342 -0
  188. package/src/server/db/util.ts +36 -0
  189. package/src/server/encryption/app.ts +133 -0
  190. package/src/server/encryption/encryption-internals.api.ts +2 -0
  191. package/src/server/encryption/encryption-internals.electron.ts +89 -0
  192. package/src/server/encryption/encryption-internals.ts +109 -0
  193. package/src/server/encryption/encryption.test.ts +19 -0
  194. package/src/server/encryption/index.test.ts +19 -0
  195. package/src/server/encryption/index.ts +89 -0
  196. package/src/server/errors.ts +110 -0
  197. package/src/server/filters/app.ts +191 -0
  198. package/src/server/importers/actual.ts +49 -0
  199. package/src/server/importers/index.ts +58 -0
  200. package/src/server/importers/ynab4-types.ts +163 -0
  201. package/src/server/importers/ynab4.ts +470 -0
  202. package/src/server/importers/ynab5-types.ts +290 -0
  203. package/src/server/importers/ynab5.ts +1193 -0
  204. package/src/server/main-app.ts +25 -0
  205. package/src/server/main.test.ts +392 -0
  206. package/src/server/main.ts +336 -0
  207. package/src/server/migrate/__snapshots__/migrations.test.ts.snap +17 -0
  208. package/src/server/migrate/cli.ts +100 -0
  209. package/src/server/migrate/migrations.test.ts +81 -0
  210. package/src/server/migrate/migrations.ts +192 -0
  211. package/src/server/models.ts +184 -0
  212. package/src/server/mutators.ts +139 -0
  213. package/src/server/notes/app.ts +18 -0
  214. package/src/server/payees/app.ts +351 -0
  215. package/src/server/polyfills.ts +26 -0
  216. package/src/server/post.ts +219 -0
  217. package/src/server/preferences/app.ts +249 -0
  218. package/src/server/prefs.ts +91 -0
  219. package/src/server/reports/app.ts +187 -0
  220. package/src/server/rules/action.ts +344 -0
  221. package/src/server/rules/app.ts +193 -0
  222. package/src/server/rules/condition.ts +436 -0
  223. package/src/server/rules/customFunctions.ts +61 -0
  224. package/src/server/rules/formula-action.test.ts +175 -0
  225. package/src/server/rules/handlebars-helpers.ts +131 -0
  226. package/src/server/rules/index.test.ts +1095 -0
  227. package/src/server/rules/index.ts +22 -0
  228. package/src/server/rules/rule-indexer.ts +89 -0
  229. package/src/server/rules/rule-utils.ts +274 -0
  230. package/src/server/rules/rule.ts +193 -0
  231. package/src/server/schedules/app.test.ts +502 -0
  232. package/src/server/schedules/app.ts +644 -0
  233. package/src/server/schedules/find-schedules.ts +391 -0
  234. package/src/server/server-config.ts +59 -0
  235. package/src/server/sheet.test.ts +101 -0
  236. package/src/server/sheet.ts +280 -0
  237. package/src/server/spreadsheet/__snapshots__/spreadsheet.test.ts.snap +5 -0
  238. package/src/server/spreadsheet/app.ts +54 -0
  239. package/src/server/spreadsheet/globals.ts +13 -0
  240. package/src/server/spreadsheet/graph-data-structure.ts +165 -0
  241. package/src/server/spreadsheet/scratch +60 -0
  242. package/src/server/spreadsheet/spreadsheet.test.ts +191 -0
  243. package/src/server/spreadsheet/spreadsheet.ts +523 -0
  244. package/src/server/spreadsheet/util.ts +15 -0
  245. package/src/server/sql/init.sql +88 -0
  246. package/src/server/sync/__snapshots__/sync.test.ts.snap +31 -0
  247. package/src/server/sync/app.ts +29 -0
  248. package/src/server/sync/encoder.ts +129 -0
  249. package/src/server/sync/index.ts +820 -0
  250. package/src/server/sync/make-test-message.ts +19 -0
  251. package/src/server/sync/migrate.test.ts +169 -0
  252. package/src/server/sync/migrate.ts +48 -0
  253. package/src/server/sync/repair.ts +39 -0
  254. package/src/server/sync/reset.ts +91 -0
  255. package/src/server/sync/sync.property.test.ts +385 -0
  256. package/src/server/sync/sync.test.ts +349 -0
  257. package/src/server/sync/utils.ts +3 -0
  258. package/src/server/tags/app.ts +101 -0
  259. package/src/server/tests/mockData.json +9352 -0
  260. package/src/server/tests/mockSyncServer.ts +119 -0
  261. package/src/server/tools/app.ts +152 -0
  262. package/src/server/transactions/__snapshots__/transaction-rules.test.ts.snap +173 -0
  263. package/src/server/transactions/__snapshots__/transfer.test.ts.snap +655 -0
  264. package/src/server/transactions/app.ts +136 -0
  265. package/src/server/transactions/export/export-to-csv.ts +132 -0
  266. package/src/server/transactions/import/__snapshots__/parse-file.test.ts.snap +1582 -0
  267. package/src/server/transactions/import/ofx2json.test.ts +33 -0
  268. package/src/server/transactions/import/ofx2json.ts +157 -0
  269. package/src/server/transactions/import/parse-file.test.ts +224 -0
  270. package/src/server/transactions/import/parse-file.ts +286 -0
  271. package/src/server/transactions/import/qif2json.ts +110 -0
  272. package/src/server/transactions/import/xmlcamt2json.ts +168 -0
  273. package/src/server/transactions/index.ts +196 -0
  274. package/src/server/transactions/merge.test.ts +370 -0
  275. package/src/server/transactions/merge.ts +139 -0
  276. package/src/server/transactions/transaction-rules.test.ts +994 -0
  277. package/src/server/transactions/transaction-rules.ts +1038 -0
  278. package/src/server/transactions/transfer.test.ts +221 -0
  279. package/src/server/transactions/transfer.ts +173 -0
  280. package/src/server/undo.ts +271 -0
  281. package/src/server/update.ts +37 -0
  282. package/src/server/util/budget-name.ts +61 -0
  283. package/src/server/util/custom-sync-mapping.ts +48 -0
  284. package/src/server/util/rschedule.ts +9 -0
  285. package/src/shared/__mocks__/platform.ts +7 -0
  286. package/src/shared/__snapshots__/months.test.ts.snap +21 -0
  287. package/src/shared/arithmetic.test.ts +112 -0
  288. package/src/shared/arithmetic.ts +170 -0
  289. package/src/shared/async.test.ts +135 -0
  290. package/src/shared/async.ts +76 -0
  291. package/src/shared/constants.ts +5 -0
  292. package/src/shared/currencies.ts +70 -0
  293. package/src/shared/dashboard.ts +260 -0
  294. package/src/shared/environment.ts +18 -0
  295. package/src/shared/errors.ts +195 -0
  296. package/src/shared/locale.ts +27 -0
  297. package/src/shared/location-utils.test.ts +69 -0
  298. package/src/shared/location-utils.ts +49 -0
  299. package/src/shared/months.test.ts +5 -0
  300. package/src/shared/months.ts +485 -0
  301. package/src/shared/normalisation.ts +6 -0
  302. package/src/shared/platform.electron.ts +21 -0
  303. package/src/shared/platform.ts +20 -0
  304. package/src/shared/query.ts +176 -0
  305. package/src/shared/rules.test.ts +56 -0
  306. package/src/shared/rules.ts +371 -0
  307. package/src/shared/schedules.test.ts +570 -0
  308. package/src/shared/schedules.ts +560 -0
  309. package/src/shared/test-helpers.ts +156 -0
  310. package/src/shared/transactions.test.ts +275 -0
  311. package/src/shared/transactions.ts +433 -0
  312. package/src/shared/transfer.test.ts +75 -0
  313. package/src/shared/transfer.ts +16 -0
  314. package/src/shared/user.ts +4 -0
  315. package/src/shared/util.test.ts +240 -0
  316. package/src/shared/util.ts +633 -0
  317. package/src/types/api-handlers.ts +287 -0
  318. package/src/types/budget.ts +8 -0
  319. package/src/types/file.ts +47 -0
  320. package/src/types/handlers.ts +46 -0
  321. package/src/types/models/account.ts +24 -0
  322. package/src/types/models/bank-sync.ts +23 -0
  323. package/src/types/models/bank.ts +6 -0
  324. package/src/types/models/category-group.ts +11 -0
  325. package/src/types/models/category.ts +13 -0
  326. package/src/types/models/dashboard.ts +199 -0
  327. package/src/types/models/gocardless.ts +84 -0
  328. package/src/types/models/import-transaction.ts +56 -0
  329. package/src/types/models/index.ts +23 -0
  330. package/src/types/models/nearby-payee.ts +7 -0
  331. package/src/types/models/note.ts +4 -0
  332. package/src/types/models/openid.ts +8 -0
  333. package/src/types/models/payee-location.ts +8 -0
  334. package/src/types/models/payee.ts +10 -0
  335. package/src/types/models/pluggyai.ts +19 -0
  336. package/src/types/models/reports.ts +144 -0
  337. package/src/types/models/rule.ts +174 -0
  338. package/src/types/models/schedule.ts +49 -0
  339. package/src/types/models/simplefin.ts +28 -0
  340. package/src/types/models/tags.ts +6 -0
  341. package/src/types/models/templates.ts +135 -0
  342. package/src/types/models/transaction-filter.ts +9 -0
  343. package/src/types/models/transaction.ts +39 -0
  344. package/src/types/models/user-access.ts +10 -0
  345. package/src/types/models/user.ts +25 -0
  346. package/src/types/prefs.ts +167 -0
  347. package/src/types/server-events.ts +86 -0
  348. package/src/types/server-handlers.ts +27 -0
  349. package/src/types/util.ts +26 -0
  350. package/tsconfig.json +34 -0
  351. package/typings/pegjs.ts +1 -0
  352. package/typings/process-worker.ts +12 -0
  353. package/typings/vite-plugin-peggy-loader.ts +1 -0
  354. package/typings/window.ts +62 -0
  355. package/vite.config.ts +109 -0
  356. package/vite.desktop.config.ts +59 -0
  357. package/vitest.config.ts +43 -0
  358. package/vitest.web.config.ts +38 -0
@@ -0,0 +1,1193 @@
1
+ // @ts-strict-ignore
2
+ import { v4 as uuidv4 } from 'uuid';
3
+
4
+ import { logger } from '../../platform/server/log';
5
+ import * as monthUtils from '../../shared/months';
6
+ import { q } from '../../shared/query';
7
+ import { groupBy, sortByKey } from '../../shared/util';
8
+ import type { RecurConfig, RecurPattern, RuleEntity } from '../../types/models';
9
+ import { send } from '../main-app';
10
+ import { ruleModel } from '../transactions/transaction-rules';
11
+
12
+ import type {
13
+ Budget,
14
+ Payee,
15
+ ScheduledSubtransaction,
16
+ ScheduledTransaction,
17
+ Subtransaction,
18
+ Transaction,
19
+ } from './ynab5-types';
20
+
21
+ const MAX_RETRY = 20;
22
+
23
+ function normalizeError(e: unknown): string {
24
+ if (e instanceof Error) {
25
+ return e.message;
26
+ }
27
+ if (typeof e === 'string') {
28
+ return e;
29
+ }
30
+ return String(e);
31
+ }
32
+
33
+ type FlaggedTransaction = Pick<
34
+ Transaction | ScheduledTransaction,
35
+ 'flag_name' | 'flag_color' | 'deleted'
36
+ >;
37
+
38
+ const flagColorMap: Record<string, string | null> = {
39
+ red: '#FF6666',
40
+ orange: '#F57C00',
41
+ yellow: '#FBC02D',
42
+ green: '#689F38',
43
+ blue: '#1976D2',
44
+ purple: '#512DA8',
45
+ null: null,
46
+ '': null,
47
+ };
48
+
49
+ function equalsIgnoreCase(stringa: string, stringb: string): boolean {
50
+ return (
51
+ stringa.localeCompare(stringb, undefined, {
52
+ sensitivity: 'base',
53
+ }) === 0
54
+ );
55
+ }
56
+
57
+ function findByNameIgnoreCase<T extends { name: string }>(
58
+ categories: T[],
59
+ name: string,
60
+ ) {
61
+ return categories.find(cat => equalsIgnoreCase(cat.name, name));
62
+ }
63
+
64
+ function findIdByName<T extends { id: string; name: string }>(
65
+ categories: Array<T>,
66
+ name: string,
67
+ ) {
68
+ return findByNameIgnoreCase<T>(categories, name)?.id;
69
+ }
70
+
71
+ function amountFromYnab(amount: number) {
72
+ // YNAB multiplies amount by 1000 and Actual by 100
73
+ // so, this function divides by 10
74
+ return Math.round(amount / 10);
75
+ }
76
+
77
+ function getDayOfMonth(date: string) {
78
+ return monthUtils.parseDate(date).getDate();
79
+ }
80
+
81
+ function getYnabMonthlyPatterns(dateFirst: string): RecurPattern[] | undefined {
82
+ if (getDayOfMonth(dateFirst) !== 31) {
83
+ return undefined;
84
+ }
85
+
86
+ return [
87
+ {
88
+ type: 'day',
89
+ value: -1,
90
+ },
91
+ ];
92
+ }
93
+
94
+ // Use Actual's "specific days" to avoid drifting every 15 days.
95
+ // This approximates YNAB's "second occurrence is 15 days after the chosen day"
96
+ // by locking to two day-of-month values.
97
+ function getYnabTwiceMonthlyPatterns(dateFirst: string): RecurPattern[] {
98
+ const firstDay = getDayOfMonth(dateFirst);
99
+ // Compute the second occurrence as 15 calendar days after the first.
100
+ const secondDay = getDayOfMonth(monthUtils.addDays(dateFirst, 15));
101
+
102
+ return [
103
+ { type: 'day', value: firstDay === 31 ? -1 : firstDay },
104
+ { type: 'day', value: secondDay === 31 ? -1 : secondDay },
105
+ ];
106
+ }
107
+
108
+ function mapYnabFrequency(
109
+ frequency: string,
110
+ dateFirst: string,
111
+ ): {
112
+ frequency: RecurConfig['frequency'];
113
+ interval?: number;
114
+ patterns?: RecurPattern[];
115
+ } {
116
+ switch (frequency) {
117
+ case 'daily':
118
+ return { frequency: 'daily' };
119
+ case 'weekly':
120
+ return { frequency: 'weekly' };
121
+ case 'monthly':
122
+ return {
123
+ frequency: 'monthly',
124
+ patterns: getYnabMonthlyPatterns(dateFirst),
125
+ };
126
+ case 'yearly':
127
+ return { frequency: 'yearly' };
128
+ case 'everyOtherWeek':
129
+ return { frequency: 'weekly', interval: 2 };
130
+ case 'every4Weeks':
131
+ return { frequency: 'weekly', interval: 4 };
132
+ case 'everyOtherMonth':
133
+ return {
134
+ frequency: 'monthly',
135
+ interval: 2,
136
+ patterns: getYnabMonthlyPatterns(dateFirst),
137
+ };
138
+ case 'every3Months':
139
+ return {
140
+ frequency: 'monthly',
141
+ interval: 3,
142
+ patterns: getYnabMonthlyPatterns(dateFirst),
143
+ };
144
+ case 'every4Months':
145
+ return {
146
+ frequency: 'monthly',
147
+ interval: 4,
148
+ patterns: getYnabMonthlyPatterns(dateFirst),
149
+ };
150
+ case 'everyOtherYear':
151
+ return { frequency: 'yearly', interval: 2 };
152
+ case 'twiceAMonth': {
153
+ return {
154
+ frequency: 'monthly',
155
+ patterns: getYnabTwiceMonthlyPatterns(dateFirst),
156
+ };
157
+ }
158
+ case 'twiceAYear': {
159
+ return {
160
+ frequency: 'monthly',
161
+ interval: 6,
162
+ patterns: getYnabMonthlyPatterns(dateFirst),
163
+ };
164
+ }
165
+ default:
166
+ throw new Error(`Unsupported scheduled frequency: ${frequency}`);
167
+ }
168
+ }
169
+
170
+ function getScheduleDateValue(
171
+ scheduled: ScheduledTransaction,
172
+ ): RecurConfig | string {
173
+ const dateFirst = scheduled.date_first;
174
+ const frequency = scheduled.frequency;
175
+
176
+ if (frequency === 'never') {
177
+ return scheduled.date_next;
178
+ }
179
+
180
+ const mapped = mapYnabFrequency(frequency, dateFirst);
181
+ return {
182
+ frequency: mapped.frequency,
183
+ interval: mapped.interval,
184
+ patterns: mapped.patterns,
185
+ skipWeekend: false,
186
+ weekendSolveMode: 'after',
187
+ endMode: 'never',
188
+ start: dateFirst,
189
+ };
190
+ }
191
+
192
+ function getFlaggedTransactions(data: Budget): FlaggedTransaction[] {
193
+ return [...data.transactions, ...data.scheduled_transactions];
194
+ }
195
+
196
+ function getFlagTag(
197
+ transaction: FlaggedTransaction,
198
+ flagNameConflicts: Set<string>,
199
+ ): string {
200
+ const tagName = transaction.flag_name?.trim() ?? '';
201
+ const colorKey = transaction.flag_color?.trim() ?? '';
202
+
203
+ if (tagName.length === 0) {
204
+ return colorKey.length > 0 ? `#${colorKey}` : '';
205
+ }
206
+
207
+ if (flagNameConflicts.has(tagName)) {
208
+ return `#${tagName}-${colorKey}`;
209
+ }
210
+
211
+ return `#${tagName}`;
212
+ }
213
+
214
+ function getFlagNameConflicts(data: Budget): Set<string> {
215
+ const colorsByName = new Map<string, Set<string>>();
216
+ const flaggedTransactions = getFlaggedTransactions(data);
217
+
218
+ for (const transaction of flaggedTransactions) {
219
+ if (transaction.deleted) {
220
+ continue;
221
+ }
222
+
223
+ const tagName = transaction.flag_name?.trim() ?? '';
224
+ const colorKey = transaction.flag_color?.trim() ?? '';
225
+ if (tagName.length === 0 || !flagColorMap[colorKey]) {
226
+ continue;
227
+ }
228
+
229
+ let colors = colorsByName.get(tagName);
230
+ if (!colors) {
231
+ colors = new Set();
232
+ colorsByName.set(tagName, colors);
233
+ }
234
+ colors.add(colorKey);
235
+ }
236
+
237
+ const conflicts = new Set<string>();
238
+ colorsByName.forEach((colors, name) => {
239
+ if (colors.size > 1) {
240
+ conflicts.add(name);
241
+ }
242
+ });
243
+
244
+ return conflicts;
245
+ }
246
+
247
+ function buildTransactionNotes(
248
+ transaction: Transaction | ScheduledTransaction,
249
+ flagNameConflicts: Set<string>,
250
+ ): string | null {
251
+ const normalizedMemo = transaction.memo?.trim() ?? '';
252
+ const tagText = getFlagTag(transaction, flagNameConflicts);
253
+ const notes = `${normalizedMemo} ${tagText}`.trim();
254
+ return notes.length > 0 ? notes : null;
255
+ }
256
+
257
+ function buildRuleUpdate(
258
+ rule: RuleEntity,
259
+ actions: RuleEntity['actions'],
260
+ ): RuleEntity {
261
+ return {
262
+ id: rule.id,
263
+ stage: rule.stage ?? null,
264
+ conditionsOp: rule.conditionsOp ?? 'and',
265
+ conditions: rule.conditions,
266
+ actions,
267
+ };
268
+ }
269
+
270
+ function importAccounts(data: Budget, entityIdMap: Map<string, string>) {
271
+ return Promise.all(
272
+ data.accounts.map(async account => {
273
+ if (!account.deleted) {
274
+ const id = await send('api/account-create', {
275
+ account: {
276
+ name: account.name,
277
+ offbudget: account.on_budget ? false : true,
278
+ closed: account.closed,
279
+ },
280
+ });
281
+ entityIdMap.set(account.id, id);
282
+ }
283
+ }),
284
+ );
285
+ }
286
+
287
+ async function importCategories(
288
+ data: Budget,
289
+ entityIdMap: Map<string, string>,
290
+ ) {
291
+ // Hidden categories are put in its own group by YNAB,
292
+ // so it's already handled.
293
+
294
+ const categories = await send('api/categories-get', {
295
+ grouped: false,
296
+ });
297
+ const incomeCatId = findIdByName(categories, 'Income');
298
+ const ynabIncomeCategories = ['To be Budgeted', 'Inflow: Ready to Assign'];
299
+
300
+ function checkSpecialCat(cat) {
301
+ if (
302
+ cat.category_group_id ===
303
+ findIdByName(data.category_groups, 'Internal Master Category')
304
+ ) {
305
+ if (
306
+ ynabIncomeCategories.some(ynabIncomeCategory =>
307
+ equalsIgnoreCase(cat.name, ynabIncomeCategory),
308
+ )
309
+ ) {
310
+ return 'income';
311
+ } else {
312
+ return 'internal';
313
+ }
314
+ } else if (
315
+ cat.category_group_id ===
316
+ findIdByName(data.category_groups, 'Credit Card Payments')
317
+ ) {
318
+ return 'creditCard';
319
+ } else if (
320
+ cat.category_group_id === findIdByName(data.category_groups, 'Income')
321
+ ) {
322
+ return 'income';
323
+ }
324
+ }
325
+ // Can't be done in parallel to have
326
+ // correct sort order.
327
+
328
+ async function createCategoryGroupWithUniqueName(params: {
329
+ name: string;
330
+ is_income: boolean;
331
+ hidden: boolean;
332
+ }) {
333
+ const baseName = params.hidden ? `${params.name} (hidden)` : params.name;
334
+ let count = 0;
335
+
336
+ while (true) {
337
+ const name = count === 0 ? baseName : `${baseName} (${count})`;
338
+ try {
339
+ const id = await send('api/category-group-create', {
340
+ group: { ...params, name },
341
+ });
342
+ return { id, name };
343
+ } catch (e) {
344
+ if (count >= MAX_RETRY) {
345
+ const errorMsg = normalizeError(e);
346
+ throw Error('Unable to create category group: ' + errorMsg);
347
+ }
348
+ count += 1;
349
+ }
350
+ }
351
+ }
352
+
353
+ async function createCategoryWithUniqueName(params: {
354
+ name: string;
355
+ group_id: string;
356
+ hidden: boolean;
357
+ }) {
358
+ const baseName = params.hidden ? `${params.name} (hidden)` : params.name;
359
+ let count = 0;
360
+
361
+ while (true) {
362
+ const name = count === 0 ? baseName : `${baseName} (${count})`;
363
+ try {
364
+ const id = await send('api/category-create', {
365
+ category: { ...params, name },
366
+ });
367
+ return { id, name };
368
+ } catch (e) {
369
+ if (count >= MAX_RETRY) {
370
+ const errorMsg = normalizeError(e);
371
+ throw Error('Unable to create category: ' + errorMsg);
372
+ }
373
+ count += 1;
374
+ }
375
+ }
376
+ }
377
+
378
+ for (const group of data.category_groups) {
379
+ if (!group.deleted) {
380
+ let groupId: string;
381
+ // Ignores internal category and credit cards
382
+ if (
383
+ !equalsIgnoreCase(group.name, 'Internal Master Category') &&
384
+ !equalsIgnoreCase(group.name, 'Credit Card Payments') &&
385
+ !equalsIgnoreCase(group.name, 'Hidden Categories') &&
386
+ !equalsIgnoreCase(group.name, 'Income')
387
+ ) {
388
+ const createdGroup = await createCategoryGroupWithUniqueName({
389
+ name: group.name,
390
+ is_income: false,
391
+ hidden: group.hidden,
392
+ });
393
+ groupId = createdGroup.id;
394
+ entityIdMap.set(group.id, groupId);
395
+ if (group.note) {
396
+ void send('notes-save', {
397
+ id: groupId,
398
+ note: group.note,
399
+ });
400
+ }
401
+ }
402
+
403
+ if (equalsIgnoreCase(group.name, 'Income')) {
404
+ groupId = incomeCatId;
405
+ entityIdMap.set(group.id, groupId);
406
+ }
407
+
408
+ const cats = data.categories.filter(
409
+ cat => cat.category_group_id === group.id,
410
+ );
411
+
412
+ for (const cat of cats.reverse()) {
413
+ if (!cat.deleted) {
414
+ // Handles special categories. Starting balance is a payee
415
+ // in YNAB so it's handled in importTransactions
416
+ switch (checkSpecialCat(cat)) {
417
+ case 'income': {
418
+ // doesn't create new category, only assigns id
419
+ const id = incomeCatId;
420
+ entityIdMap.set(cat.id, id);
421
+ break;
422
+ }
423
+ case 'creditCard': // ignores it
424
+ case 'internal': // uncategorized is ignored too, handled by actual
425
+ break;
426
+ default: {
427
+ if (!groupId) {
428
+ break;
429
+ }
430
+ const createdCategory = await createCategoryWithUniqueName({
431
+ name: cat.name,
432
+ group_id: groupId,
433
+ hidden: cat.hidden,
434
+ });
435
+ entityIdMap.set(cat.id, createdCategory.id);
436
+ if (cat.note) {
437
+ void send('notes-save', {
438
+ id: createdCategory.id,
439
+ note: cat.note,
440
+ });
441
+ }
442
+ }
443
+ }
444
+ }
445
+ }
446
+ }
447
+ }
448
+ }
449
+
450
+ function importPayees(data: Budget, entityIdMap: Map<string, string>) {
451
+ return Promise.all(
452
+ data.payees.map(async payee => {
453
+ if (!payee.deleted) {
454
+ const id = await send('api/payee-create', {
455
+ payee: { name: payee.name },
456
+ });
457
+ entityIdMap.set(payee.id, id);
458
+ }
459
+ }),
460
+ );
461
+ }
462
+
463
+ async function importPayeeLocations(
464
+ data: Budget,
465
+ entityIdMap: Map<string, string>,
466
+ ) {
467
+ // If no payee locations data provided, skip import
468
+ if (!data?.payee_locations) {
469
+ logger.log('No payee locations data provided, skipping...');
470
+ return;
471
+ }
472
+
473
+ const payeeLocations = data.payee_locations;
474
+
475
+ for (const location of payeeLocations) {
476
+ // Skip deleted locations
477
+ if (location.deleted) {
478
+ continue;
479
+ }
480
+
481
+ // Get the mapped payee ID
482
+ const actualPayeeId = entityIdMap.get(location.payee_id);
483
+ if (!actualPayeeId) {
484
+ logger.log(`Skipping location for unknown payee: ${location.payee_id}`);
485
+ continue;
486
+ }
487
+
488
+ // Validate latitude/longitude before attempting import
489
+ const latitude = parseFloat(location.latitude);
490
+ const longitude = parseFloat(location.longitude);
491
+
492
+ if (isNaN(latitude) || isNaN(longitude)) {
493
+ logger.log(
494
+ `Skipping location with invalid coordinates for payee ${actualPayeeId}: lat=${location.latitude}, lng=${location.longitude}`,
495
+ );
496
+ continue;
497
+ }
498
+
499
+ try {
500
+ // Create the payee location in Actual
501
+ await send('payee-location-create', {
502
+ payeeId: actualPayeeId,
503
+ latitude,
504
+ longitude,
505
+ });
506
+ } catch (error) {
507
+ const errorMessage =
508
+ error instanceof Error
509
+ ? error.message
510
+ : String(error ?? 'Unknown error');
511
+ logger.error(
512
+ `Failed to import location for payee ${actualPayeeId} at (${latitude}, ${longitude}): ${errorMessage}`,
513
+ );
514
+ }
515
+ }
516
+ }
517
+
518
+ async function importFlagsAsTags(
519
+ data: Budget,
520
+ flagNameConflicts: Set<string>,
521
+ ): Promise<void> {
522
+ const tagsToCreate = new Map<string, string | null>();
523
+ const flaggedTransactions = getFlaggedTransactions(data);
524
+
525
+ for (const transaction of flaggedTransactions) {
526
+ if (transaction.deleted) {
527
+ continue;
528
+ }
529
+
530
+ const tagName = transaction.flag_name?.trim() ?? '';
531
+ const colorKey = transaction.flag_color?.trim() ?? '';
532
+ const tagColor = flagColorMap[colorKey] ?? null;
533
+
534
+ if (!tagColor) {
535
+ continue;
536
+ }
537
+
538
+ if (tagName.length === 0) {
539
+ if (!tagsToCreate.has(colorKey)) {
540
+ tagsToCreate.set(colorKey, tagColor);
541
+ }
542
+ continue;
543
+ }
544
+
545
+ const mappedName = flagNameConflicts.has(tagName)
546
+ ? `${tagName}-${colorKey}`
547
+ : tagName;
548
+
549
+ if (!tagsToCreate.has(mappedName)) {
550
+ tagsToCreate.set(mappedName, tagColor);
551
+ }
552
+ }
553
+
554
+ if (tagsToCreate.size === 0) {
555
+ return;
556
+ }
557
+
558
+ await Promise.all(
559
+ [...tagsToCreate.entries()].map(async ([tag, color]) => {
560
+ await send('tags-create', {
561
+ tag,
562
+ color,
563
+ description: 'Imported from YNAB',
564
+ });
565
+ }),
566
+ );
567
+ }
568
+
569
+ async function importTransactions(
570
+ data: Budget,
571
+ entityIdMap: Map<string, string>,
572
+ flagNameConflicts: Set<string>,
573
+ ) {
574
+ const payees = await send('api/payees-get');
575
+ const categories = await send('api/categories-get', {
576
+ grouped: false,
577
+ });
578
+ const incomeCatId = findIdByName(categories, 'Income');
579
+ const startingBalanceCatId = findIdByName(categories, 'Starting Balances'); //better way to do it?
580
+
581
+ const startingPayeeYNAB = findIdByName(data.payees, 'Starting Balance');
582
+
583
+ const transactionsGrouped = groupBy(data.transactions, 'account_id');
584
+ const subtransactionsGrouped = groupBy(
585
+ data.subtransactions,
586
+ 'transaction_id',
587
+ );
588
+
589
+ const payeesByTransferAcct = payees
590
+ .filter(payee => payee?.transfer_acct)
591
+ .map(payee => [payee.transfer_acct, payee] as [string, Payee]);
592
+ const payeeTransferAcctHashMap = new Map<string, Payee>(payeesByTransferAcct);
593
+ const orphanTransferMap = new Map<string, Transaction[]>();
594
+ const orphanSubtransfer = [] as Subtransaction[];
595
+ const orphanSubtransferTrxId = [] as string[];
596
+ const orphanSubtransferAcctIdByTrxIdMap = new Map<string, string>();
597
+ const orphanSubtransferDateByTrxIdMap = new Map<string, string>();
598
+
599
+ // Go ahead and generate ids for all of the transactions so we can
600
+ // reliably resolve transfers
601
+ // Also identify orphan transfer transactions and subtransactions.
602
+ for (const transaction of data.subtransactions) {
603
+ entityIdMap.set(transaction.id, uuidv4());
604
+
605
+ if (transaction.transfer_account_id) {
606
+ orphanSubtransfer.push(transaction);
607
+ orphanSubtransferTrxId.push(transaction.transaction_id);
608
+ }
609
+ }
610
+
611
+ for (const transaction of data.transactions) {
612
+ entityIdMap.set(transaction.id, uuidv4());
613
+
614
+ if (
615
+ transaction.transfer_account_id &&
616
+ !transaction.transfer_transaction_id
617
+ ) {
618
+ const key =
619
+ transaction.account_id + '#' + transaction.transfer_account_id;
620
+ if (!orphanTransferMap.has(key)) {
621
+ orphanTransferMap.set(key, [transaction]);
622
+ } else {
623
+ orphanTransferMap.get(key).push(transaction);
624
+ }
625
+ }
626
+
627
+ if (orphanSubtransferTrxId.includes(transaction.id)) {
628
+ orphanSubtransferAcctIdByTrxIdMap.set(
629
+ transaction.id,
630
+ transaction.account_id,
631
+ );
632
+ orphanSubtransferDateByTrxIdMap.set(transaction.id, transaction.date);
633
+ }
634
+ }
635
+
636
+ // Compute link between subtransaction transfers and orphaned transaction
637
+ // transfers. The goal is to match each transfer subtransaction to the related
638
+ // transfer transaction according to the accounts, date, amount and memo.
639
+ const orphanSubtransferMap = orphanSubtransfer.reduce(
640
+ (map, subtransaction) => {
641
+ const key =
642
+ subtransaction.transfer_account_id +
643
+ '#' +
644
+ orphanSubtransferAcctIdByTrxIdMap.get(subtransaction.transaction_id);
645
+ if (!map.has(key)) {
646
+ map.set(key, [subtransaction]);
647
+ } else {
648
+ map.get(key).push(subtransaction);
649
+ }
650
+ return map;
651
+ },
652
+ new Map<string, Subtransaction[]>(),
653
+ );
654
+
655
+ // The comparator will be used to order transfer transactions and their
656
+ // corresponding tranfer subtransaction in two aligned list. Hopefully
657
+ // for every list index in the transactions list, the related subtransaction
658
+ // will be at the same index.
659
+ function orphanTransferComparator(
660
+ a: Transaction | Subtransaction,
661
+ b: Transaction | Subtransaction,
662
+ ) {
663
+ // a and b can be a Transaction (having a date attribute) or a
664
+ // Subtransaction (missing that date attribute)
665
+
666
+ const date_a =
667
+ 'date' in a
668
+ ? a.date
669
+ : orphanSubtransferDateByTrxIdMap.get(a.transaction_id);
670
+ const date_b =
671
+ 'date' in b
672
+ ? b.date
673
+ : orphanSubtransferDateByTrxIdMap.get(b.transaction_id);
674
+ // A transaction and the related subtransaction have inverted amounts.
675
+ // To have those in the same order, the subtransaction has to be reversed
676
+ // to have the same amount.
677
+ const amount_a = 'date' in a ? a.amount : -a.amount;
678
+ const amount_b = 'date' in b ? b.amount : -b.amount;
679
+
680
+ // Transaction are ordered first by date, then by amount, and lastly by memo
681
+ if (date_a > date_b) return 1;
682
+ if (date_a < date_b) return -1;
683
+ if (amount_a > amount_b) return 1;
684
+ if (amount_a < amount_b) return -1;
685
+ if (a.memo > b.memo) return 1;
686
+ if (a.memo < b.memo) return -1;
687
+ return 0;
688
+ }
689
+
690
+ const orphanTrxIdSubtrxIdMap = new Map<string, string>();
691
+ orphanTransferMap.forEach((transactions, key) => {
692
+ const subtransactions = orphanSubtransferMap.get(key);
693
+ if (subtransactions) {
694
+ transactions.sort(orphanTransferComparator);
695
+ subtransactions.sort(orphanTransferComparator);
696
+
697
+ // Iterate on the two sorted lists transactions and subtransactions and
698
+ // find matching data to identify the related transaction ids.
699
+ let transactionIdx = 0;
700
+ let subtransactionIdx = 0;
701
+ do {
702
+ switch (
703
+ orphanTransferComparator(
704
+ transactions[transactionIdx],
705
+ subtransactions[subtransactionIdx],
706
+ )
707
+ ) {
708
+ case 0:
709
+ // The current list indexes are matching: the transaction and
710
+ // subtransaction are related (same date, amount and memo)
711
+ orphanTrxIdSubtrxIdMap.set(
712
+ transactions[transactionIdx].id,
713
+ entityIdMap.get(subtransactions[subtransactionIdx].id),
714
+ );
715
+ orphanTrxIdSubtrxIdMap.set(
716
+ subtransactions[subtransactionIdx].id,
717
+ entityIdMap.get(transactions[transactionIdx].id),
718
+ );
719
+ transactionIdx++;
720
+ subtransactionIdx++;
721
+ break;
722
+ case -1:
723
+ // The current list indexes are not matching:
724
+ // The current transaction is "smaller" than the current subtransaction
725
+ // (earlier date, smaller amount, memo value sorted before)
726
+ // So we advance to the next transaction and see if it match with
727
+ // the current subtransaction
728
+ transactionIdx++;
729
+ break;
730
+ case 1:
731
+ // Inverse of the previous case:
732
+ // The current subtransaction is "smaller" than the current transaction
733
+ // So we advance to the next subtransaction
734
+ subtransactionIdx++;
735
+ break;
736
+ default:
737
+ throw new Error(`Unrecognized orphan transfer comparator result`);
738
+ }
739
+ } while (
740
+ transactionIdx < transactions.length &&
741
+ subtransactionIdx < subtransactions.length
742
+ );
743
+ }
744
+ });
745
+
746
+ await Promise.all(
747
+ [...transactionsGrouped.keys()].map(async accountId => {
748
+ const transactions = transactionsGrouped.get(accountId);
749
+
750
+ const toImport = transactions
751
+ .map(transaction => {
752
+ if (transaction.deleted) {
753
+ return null;
754
+ }
755
+
756
+ const subtransactions = subtransactionsGrouped.get(transaction.id);
757
+
758
+ // Add transaction
759
+ const newTransaction = {
760
+ id: entityIdMap.get(transaction.id),
761
+ account: entityIdMap.get(transaction.account_id),
762
+ date: transaction.date,
763
+ amount: amountFromYnab(transaction.amount),
764
+ category: entityIdMap.get(transaction.category_id) || null,
765
+ cleared: ['cleared', 'reconciled'].includes(transaction.cleared),
766
+ reconciled: transaction.cleared === 'reconciled',
767
+ notes: buildTransactionNotes(transaction, flagNameConflicts),
768
+ imported_id: transaction.import_id || null,
769
+ transfer_id:
770
+ entityIdMap.get(transaction.transfer_transaction_id) ||
771
+ orphanTrxIdSubtrxIdMap.get(transaction.id) ||
772
+ null,
773
+ subtransactions: subtransactions
774
+ ? subtransactions.map(subtrans => {
775
+ return {
776
+ id: entityIdMap.get(subtrans.id),
777
+ amount: amountFromYnab(subtrans.amount),
778
+ category: entityIdMap.get(subtrans.category_id) || null,
779
+ notes: subtrans.memo,
780
+ transfer_id:
781
+ orphanTrxIdSubtrxIdMap.get(subtrans.id) || null,
782
+ payee: null,
783
+ imported_payee: null,
784
+ };
785
+ })
786
+ : null,
787
+ payee: null,
788
+ imported_payee: null,
789
+ };
790
+
791
+ // Handle transactions and subtransactions payee
792
+ function transactionPayeeUpdate(
793
+ trx: Transaction | Subtransaction,
794
+ newTrx,
795
+ fallbackPayeeId?: string | null,
796
+ ) {
797
+ if (trx.transfer_account_id) {
798
+ const mappedTransferAccountId = entityIdMap.get(
799
+ trx.transfer_account_id,
800
+ );
801
+ newTrx.payee = payeeTransferAcctHashMap.get(
802
+ mappedTransferAccountId,
803
+ )?.id;
804
+ } else if (trx.payee_id) {
805
+ newTrx.payee = entityIdMap.get(trx.payee_id);
806
+ newTrx.imported_payee = data.payees.find(
807
+ p => !p.deleted && p.id === trx.payee_id,
808
+ )?.name;
809
+ } else if (fallbackPayeeId) {
810
+ newTrx.payee = fallbackPayeeId;
811
+ }
812
+ }
813
+
814
+ transactionPayeeUpdate(transaction, newTransaction);
815
+ if (newTransaction.subtransactions) {
816
+ subtransactions.forEach(subtrans => {
817
+ const newSubtransaction = newTransaction.subtransactions.find(
818
+ newSubtrans => newSubtrans.id === entityIdMap.get(subtrans.id),
819
+ );
820
+ transactionPayeeUpdate(
821
+ subtrans,
822
+ newSubtransaction,
823
+ newTransaction.payee,
824
+ );
825
+ });
826
+ }
827
+
828
+ // Handle starting balances
829
+ if (
830
+ transaction.payee_id === startingPayeeYNAB &&
831
+ entityIdMap.get(transaction.category_id) === incomeCatId
832
+ ) {
833
+ newTransaction.category = startingBalanceCatId;
834
+ newTransaction.payee = null;
835
+ }
836
+ return newTransaction;
837
+ })
838
+ .filter(x => x);
839
+
840
+ await send('api/transactions-add', {
841
+ accountId: entityIdMap.get(accountId),
842
+ transactions: toImport,
843
+ learnCategories: true,
844
+ runTransfers: false,
845
+ });
846
+ }),
847
+ );
848
+ }
849
+
850
+ async function importScheduledTransactions(
851
+ data: Budget,
852
+ entityIdMap: Map<string, string>,
853
+ flagNameConflicts: Set<string>,
854
+ ) {
855
+ const scheduledTransactions = data.scheduled_transactions;
856
+ const scheduledSubtransactionsGrouped = groupBy(
857
+ data.scheduled_subtransactions,
858
+ 'scheduled_transaction_id',
859
+ );
860
+ if (scheduledTransactions.length === 0) {
861
+ return;
862
+ }
863
+
864
+ const payees = await send('api/payees-get');
865
+ const payeesByTransferAcct = payees
866
+ .filter(payee => payee?.transfer_acct)
867
+ .map(payee => [payee.transfer_acct, payee] as [string, Payee]);
868
+ const payeeTransferAcctHashMap = new Map<string, Payee>(payeesByTransferAcct);
869
+ const scheduleCategoryMap = new Map<string, string>();
870
+ const scheduleSplitsMap = new Map<string, ScheduledSubtransaction[]>();
871
+ const schedulePayeeMap = new Map<string, string>();
872
+
873
+ async function createScheduleWithUniqueName(params: {
874
+ name: string;
875
+ posts_transaction: boolean;
876
+ payee: string;
877
+ account: string;
878
+ amount: number;
879
+ amountOp: 'is';
880
+ date: RecurConfig | string;
881
+ }) {
882
+ const baseName = params.name;
883
+ let count = 1;
884
+
885
+ while (true) {
886
+ try {
887
+ return await send('api/schedule-create', {
888
+ ...params,
889
+ name: params.name,
890
+ });
891
+ } catch (e) {
892
+ if (count >= MAX_RETRY) {
893
+ const errorMsg = normalizeError(e);
894
+ throw Error(errorMsg);
895
+ }
896
+ params.name = `${baseName} (${count})`;
897
+ count += 1;
898
+ }
899
+ }
900
+ }
901
+
902
+ async function getRuleForSchedule(
903
+ scheduleId: string,
904
+ ): Promise<RuleEntity | null> {
905
+ const { data: ruleId } = (await send('api/query', {
906
+ query: q('schedules')
907
+ .filter({ id: scheduleId })
908
+ .calculate('rule')
909
+ .serialize(),
910
+ })) as { data: string | null };
911
+ if (!ruleId) {
912
+ return null;
913
+ }
914
+
915
+ const { data: ruleData } = (await send('api/query', {
916
+ query: q('rules').filter({ id: ruleId }).select('*').serialize(),
917
+ })) as { data: Array<Record<string, unknown>> };
918
+ const ruleRow = ruleData?.[0];
919
+ if (!ruleRow) {
920
+ return null;
921
+ }
922
+
923
+ return ruleModel.toJS(ruleRow);
924
+ }
925
+
926
+ for (const scheduled of scheduledTransactions) {
927
+ if (scheduled.deleted) {
928
+ continue;
929
+ }
930
+
931
+ const mappedAccountId = entityIdMap.get(scheduled.account_id);
932
+ if (!mappedAccountId) {
933
+ continue;
934
+ }
935
+
936
+ const scheduleDate = getScheduleDateValue(scheduled);
937
+
938
+ let mappedPayeeId: string | undefined;
939
+ if (scheduled.transfer_account_id) {
940
+ const mappedTransferAccountId = entityIdMap.get(
941
+ scheduled.transfer_account_id,
942
+ );
943
+ mappedPayeeId = mappedTransferAccountId
944
+ ? payeeTransferAcctHashMap.get(mappedTransferAccountId)?.id
945
+ : undefined;
946
+ } else if (scheduled.payee_id) {
947
+ mappedPayeeId = entityIdMap.get(scheduled.payee_id);
948
+ }
949
+
950
+ if (!mappedPayeeId) {
951
+ continue;
952
+ }
953
+
954
+ const scheduleId = await createScheduleWithUniqueName({
955
+ name: scheduled.memo,
956
+ posts_transaction: false,
957
+ payee: mappedPayeeId,
958
+ account: mappedAccountId,
959
+ amount: amountFromYnab(scheduled.amount),
960
+ amountOp: 'is',
961
+ date: scheduleDate,
962
+ });
963
+ schedulePayeeMap.set(scheduleId, mappedPayeeId);
964
+
965
+ const scheduleNotes = buildTransactionNotes(scheduled, flagNameConflicts);
966
+ if (scheduleNotes) {
967
+ const rule = await getRuleForSchedule(scheduleId);
968
+ if (rule) {
969
+ const actions = rule.actions ? [...rule.actions] : [];
970
+ actions.push({
971
+ op: 'set',
972
+ field: 'notes',
973
+ value: scheduleNotes,
974
+ });
975
+
976
+ await send('api/rule-update', {
977
+ rule: buildRuleUpdate(rule, actions),
978
+ });
979
+ }
980
+ }
981
+
982
+ const scheduledSubtransactions =
983
+ scheduledSubtransactionsGrouped
984
+ .get(scheduled.id)
985
+ ?.filter(subtransaction => !subtransaction.deleted) || [];
986
+
987
+ if (scheduledSubtransactions.length > 0) {
988
+ scheduleSplitsMap.set(scheduleId, scheduledSubtransactions);
989
+ } else if (!scheduled.transfer_account_id && scheduled.category_id) {
990
+ const mappedCategoryId = entityIdMap.get(scheduled.category_id);
991
+ if (mappedCategoryId) {
992
+ scheduleCategoryMap.set(scheduleId, mappedCategoryId);
993
+ }
994
+ }
995
+ }
996
+
997
+ if (scheduleCategoryMap.size > 0 || scheduleSplitsMap.size > 0) {
998
+ for (const [scheduleId, categoryId] of scheduleCategoryMap.entries()) {
999
+ const rule = await getRuleForSchedule(scheduleId);
1000
+ if (!rule) {
1001
+ continue;
1002
+ }
1003
+
1004
+ const actions = rule.actions ? [...rule.actions] : [];
1005
+ actions.push({
1006
+ op: 'set',
1007
+ field: 'category',
1008
+ value: categoryId,
1009
+ });
1010
+
1011
+ await send('api/rule-update', {
1012
+ rule: buildRuleUpdate(rule, actions),
1013
+ });
1014
+ }
1015
+
1016
+ for (const [scheduleId, subtransactions] of scheduleSplitsMap.entries()) {
1017
+ const rule = await getRuleForSchedule(scheduleId);
1018
+ if (!rule) {
1019
+ continue;
1020
+ }
1021
+
1022
+ const actions = rule.actions ? [...rule.actions] : [];
1023
+ const parentPayeeId = schedulePayeeMap.get(scheduleId);
1024
+
1025
+ subtransactions.forEach((subtransaction, index) => {
1026
+ const splitIndex = index + 1;
1027
+
1028
+ actions.push({
1029
+ op: 'set-split-amount',
1030
+ value: amountFromYnab(subtransaction.amount),
1031
+ options: { splitIndex, method: 'fixed-amount' },
1032
+ });
1033
+
1034
+ if (subtransaction.memo) {
1035
+ actions.push({
1036
+ op: 'set',
1037
+ field: 'notes',
1038
+ value: subtransaction.memo,
1039
+ options: { splitIndex },
1040
+ });
1041
+ }
1042
+
1043
+ if (subtransaction.transfer_account_id) {
1044
+ const mappedTransferAccountId = entityIdMap.get(
1045
+ subtransaction.transfer_account_id,
1046
+ );
1047
+ const transferPayeeId = mappedTransferAccountId
1048
+ ? payeeTransferAcctHashMap.get(mappedTransferAccountId)?.id
1049
+ : undefined;
1050
+ if (transferPayeeId) {
1051
+ actions.push({
1052
+ op: 'set',
1053
+ field: 'payee',
1054
+ value: transferPayeeId,
1055
+ options: { splitIndex },
1056
+ });
1057
+ }
1058
+ } else if (subtransaction.payee_id) {
1059
+ const mappedPayeeId = entityIdMap.get(subtransaction.payee_id);
1060
+ if (mappedPayeeId) {
1061
+ actions.push({
1062
+ op: 'set',
1063
+ field: 'payee',
1064
+ value: mappedPayeeId,
1065
+ options: { splitIndex },
1066
+ });
1067
+ }
1068
+ } else if (parentPayeeId) {
1069
+ actions.push({
1070
+ op: 'set',
1071
+ field: 'payee',
1072
+ value: parentPayeeId,
1073
+ options: { splitIndex },
1074
+ });
1075
+ }
1076
+
1077
+ if (!subtransaction.transfer_account_id && subtransaction.category_id) {
1078
+ const mappedCategoryId = entityIdMap.get(subtransaction.category_id);
1079
+ if (mappedCategoryId) {
1080
+ actions.push({
1081
+ op: 'set',
1082
+ field: 'category',
1083
+ value: mappedCategoryId,
1084
+ options: { splitIndex },
1085
+ });
1086
+ }
1087
+ }
1088
+ });
1089
+
1090
+ await send('api/rule-update', {
1091
+ rule: buildRuleUpdate(rule, actions),
1092
+ });
1093
+ }
1094
+ }
1095
+ }
1096
+
1097
+ async function importBudgets(data: Budget, entityIdMap: Map<string, string>) {
1098
+ // There should be info in the docs to deal with
1099
+ // no credit card category and how YNAB and Actual
1100
+ // handle differently the amount To be Budgeted
1101
+ // i.e. Actual considers the cc debt while YNAB doesn't
1102
+ //
1103
+ // Also, there could be a way to set rollover using
1104
+ // Deferred Income Subcat and Immediate Income Subcat
1105
+
1106
+ const budgets = sortByKey(data.months, 'month');
1107
+
1108
+ const internalCatIdYnab = findIdByName(
1109
+ data.category_groups,
1110
+ 'Internal Master Category',
1111
+ );
1112
+ const creditcardCatIdYnab = findIdByName(
1113
+ data.category_groups,
1114
+ 'Credit Card Payments',
1115
+ );
1116
+
1117
+ await send('api/batch-budget-start');
1118
+ try {
1119
+ for (const budget of budgets) {
1120
+ const month = monthUtils.monthFromDate(budget.month);
1121
+
1122
+ await Promise.all(
1123
+ budget.categories.map(async catBudget => {
1124
+ const catId = entityIdMap.get(catBudget.id);
1125
+ const amount = Math.round(catBudget.budgeted / 10);
1126
+
1127
+ if (
1128
+ !catId ||
1129
+ catBudget.category_group_id === internalCatIdYnab ||
1130
+ catBudget.category_group_id === creditcardCatIdYnab
1131
+ ) {
1132
+ return;
1133
+ }
1134
+
1135
+ await send('api/budget-set-amount', {
1136
+ month,
1137
+ categoryId: catId,
1138
+ amount,
1139
+ });
1140
+ }),
1141
+ );
1142
+ }
1143
+ } finally {
1144
+ await send('api/batch-budget-end');
1145
+ }
1146
+ }
1147
+
1148
+ export function parseFile(buffer: Buffer): Budget {
1149
+ let data = JSON.parse(buffer.toString());
1150
+ if (data.data) {
1151
+ data = data.data;
1152
+ }
1153
+ if (data.budget) {
1154
+ data = data.budget;
1155
+ }
1156
+
1157
+ return data;
1158
+ }
1159
+
1160
+ export function getBudgetName(_filepath: string, data: Budget) {
1161
+ return data.budget_name || data.name;
1162
+ }
1163
+
1164
+ export async function doImport(data: Budget) {
1165
+ const entityIdMap = new Map<string, string>();
1166
+ const flagNameConflicts = getFlagNameConflicts(data);
1167
+
1168
+ logger.log('Importing Accounts...');
1169
+ await importAccounts(data, entityIdMap);
1170
+
1171
+ logger.log('Importing Categories...');
1172
+ await importCategories(data, entityIdMap);
1173
+
1174
+ logger.log('Importing Payees...');
1175
+ await importPayees(data, entityIdMap);
1176
+
1177
+ logger.log('Importing Payee Locations...');
1178
+ await importPayeeLocations(data, entityIdMap);
1179
+
1180
+ logger.log('Importing Tags...');
1181
+ await importFlagsAsTags(data, flagNameConflicts);
1182
+
1183
+ logger.log('Importing Transactions...');
1184
+ await importTransactions(data, entityIdMap, flagNameConflicts);
1185
+
1186
+ logger.log('Importing Scheduled Transactions...');
1187
+ await importScheduledTransactions(data, entityIdMap, flagNameConflicts);
1188
+
1189
+ logger.log('Importing Budgets...');
1190
+ await importBudgets(data, entityIdMap);
1191
+
1192
+ logger.log('Setting up...');
1193
+ }