@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,221 @@
1
+ // @ts-strict-ignore
2
+ import { expectSnapshotWithDiffer } from '../../mocks/util';
3
+ import * as db from '../db';
4
+
5
+ import * as transfer from './transfer';
6
+
7
+ beforeEach(global.emptyDatabase());
8
+
9
+ function getAllTransactions() {
10
+ return db.all<db.DbViewTransaction & { payee_name: db.DbPayee['name'] }>(
11
+ `SELECT t.*, p.name as payee_name
12
+ FROM v_transactions t
13
+ LEFT JOIN payees p ON p.id = t.payee
14
+ ORDER BY date DESC, amount DESC, id
15
+ `,
16
+ );
17
+ }
18
+
19
+ async function prepareDatabase() {
20
+ await db.insertCategoryGroup({ id: 'group1', name: 'group1', is_income: 0 });
21
+ await db.insertCategory({
22
+ id: '1',
23
+ name: 'cat1',
24
+ cat_group: 'group1',
25
+ is_income: 0,
26
+ });
27
+ await db.insertAccount({ id: 'one', name: 'one' });
28
+ await db.insertAccount({ id: 'two', name: 'two' });
29
+ await db.insertAccount({ id: 'three', name: 'three', offbudget: 1 });
30
+ await db.insertPayee({ name: '', transfer_acct: 'one' });
31
+ await db.insertPayee({ name: '', transfer_acct: 'two' });
32
+ await db.insertPayee({
33
+ name: '',
34
+ transfer_acct: 'three',
35
+ });
36
+ }
37
+
38
+ type Transaction = {
39
+ account: string;
40
+ amount: number;
41
+ category?: string;
42
+ date: string;
43
+ id?: string;
44
+ notes?: string;
45
+ payee: string;
46
+ transfer_id?: string;
47
+ is_parent?: boolean;
48
+ is_child?: boolean;
49
+ parent_id?: string;
50
+ };
51
+
52
+ describe('Transfer', () => {
53
+ test('transfers are properly inserted/updated/deleted', async () => {
54
+ await prepareDatabase();
55
+
56
+ let transaction: Transaction = {
57
+ account: 'one',
58
+ amount: 5000,
59
+ payee: await db.insertPayee({ name: 'Non-transfer' }),
60
+ date: '2017-01-01',
61
+ };
62
+ await db.insertTransaction(transaction);
63
+ await transfer.onInsert(transaction);
64
+
65
+ const differ = expectSnapshotWithDiffer(await getAllTransactions());
66
+
67
+ const transferTwo = await db.first<db.DbPayee>(
68
+ "SELECT * FROM payees WHERE transfer_acct = 'two'",
69
+ );
70
+ const transferThree = await db.first<db.DbPayee>(
71
+ "SELECT * FROM payees WHERE transfer_acct = 'three'",
72
+ );
73
+
74
+ transaction = {
75
+ account: 'one',
76
+ amount: 5000,
77
+ payee: transferTwo.id,
78
+ date: '2017-01-01',
79
+ };
80
+ transaction.id = await db.insertTransaction(transaction);
81
+ await transfer.onInsert(transaction);
82
+ differ.expectToMatchDiff(await getAllTransactions());
83
+
84
+ // Fill the transaction out
85
+ transaction = await db.getTransaction(transaction.id);
86
+ expect(transaction.transfer_id).toBeDefined();
87
+
88
+ transaction = {
89
+ ...transaction,
90
+ date: '2017-01-05',
91
+ notes: 'This is a note',
92
+ };
93
+ await db.updateTransaction(transaction);
94
+ await transfer.onUpdate(transaction);
95
+ differ.expectToMatchDiff(await getAllTransactions());
96
+
97
+ transaction = {
98
+ ...transaction,
99
+ payee: transferThree.id,
100
+ };
101
+ await db.updateTransaction(transaction);
102
+ await transfer.onUpdate(transaction);
103
+ differ.expectToMatchDiff(await getAllTransactions());
104
+
105
+ transaction = {
106
+ ...transaction,
107
+ payee: await db.insertPayee({ name: 'Not transferred anymore' }),
108
+ };
109
+ await db.updateTransaction(transaction);
110
+ await transfer.onUpdate(transaction);
111
+ differ.expectToMatchDiff(await getAllTransactions());
112
+
113
+ // Make sure it's not a linked transaction anymore
114
+ transaction = await db.getTransaction(transaction.id);
115
+ expect(transaction.transfer_id).toBeNull();
116
+
117
+ // Re-transfer it
118
+ transaction = {
119
+ ...transaction,
120
+ payee: transferTwo.id,
121
+ };
122
+ await db.updateTransaction(transaction);
123
+ await transfer.onUpdate(transaction);
124
+ differ.expectToMatchDiff(await getAllTransactions());
125
+
126
+ transaction = await db.getTransaction(transaction.id);
127
+ expect(transaction.transfer_id).toBeDefined();
128
+
129
+ await db.deleteTransaction(transaction);
130
+ await transfer.onDelete(transaction);
131
+ differ.expectToMatchDiff(await getAllTransactions());
132
+ });
133
+
134
+ test('transfers are properly de-categorized', async () => {
135
+ await prepareDatabase();
136
+
137
+ const transferTwo = await db.first<db.DbPayee>(
138
+ "SELECT * FROM payees WHERE transfer_acct = 'two'",
139
+ );
140
+ const transferThree = await db.first<db.DbPayee>(
141
+ "SELECT * FROM payees WHERE transfer_acct = 'three'",
142
+ );
143
+
144
+ let transaction: Transaction = {
145
+ account: 'one',
146
+ amount: 5000,
147
+ payee: await db.insertPayee({ name: 'Non-transfer' }),
148
+ date: '2017-01-01',
149
+ category: '1',
150
+ };
151
+ transaction.id = await db.insertTransaction(transaction);
152
+ await transfer.onInsert(transaction);
153
+
154
+ const differ = expectSnapshotWithDiffer(await getAllTransactions());
155
+
156
+ transaction = {
157
+ ...(await db.getTransaction(transaction.id)),
158
+ payee: transferThree.id,
159
+ notes: 'hi',
160
+ };
161
+ await db.updateTransaction(transaction);
162
+ await transfer.onUpdate(transaction);
163
+ differ.expectToMatchDiff(await getAllTransactions());
164
+
165
+ transaction = {
166
+ ...(await db.getTransaction(transaction.id)),
167
+ payee: transferTwo.id,
168
+ };
169
+ await db.updateTransaction(transaction);
170
+ await transfer.onUpdate(transaction);
171
+ differ.expectToMatchDiff(await getAllTransactions());
172
+ });
173
+
174
+ test('split transfers are retained on child transactions', async () => {
175
+ // test: first add a txn having a transfer acct payee
176
+ // then mark it as `is_parent` and add a child txn
177
+ // the child txn should have a different transfer acct payee
178
+ // and `is_child` set to true
179
+ await prepareDatabase();
180
+
181
+ const [transferOne, transferTwo] = await Promise.all([
182
+ db.first<db.DbPayee>("SELECT * FROM payees WHERE transfer_acct = 'one'"),
183
+ db.first<db.DbPayee>("SELECT * FROM payees WHERE transfer_acct = 'two'"),
184
+ ]);
185
+
186
+ let parent: Transaction = {
187
+ account: 'one',
188
+ amount: 5000,
189
+ payee: transferTwo.id,
190
+ date: '2017-01-01',
191
+ };
192
+ parent.id = await db.insertTransaction(parent);
193
+ await transfer.onInsert(parent);
194
+ parent = await db.getTransaction(parent.id);
195
+
196
+ const differ = expectSnapshotWithDiffer(await getAllTransactions());
197
+
198
+ // mark the txn as parent
199
+ await db.updateTransaction({ id: parent.id, is_parent: true });
200
+ await transfer.onUpdate(parent);
201
+ differ.expectToMatchDiff(await getAllTransactions());
202
+
203
+ // add a child txn
204
+ let child: Transaction = {
205
+ account: 'one',
206
+ amount: 2000,
207
+ payee: transferOne.id,
208
+ date: '2017-01-01',
209
+ is_child: true,
210
+ parent_id: parent.id,
211
+ };
212
+ child.id = await db.insertTransaction(child);
213
+ await transfer.onInsert(child);
214
+ differ.expectToMatchDiff(await getAllTransactions());
215
+
216
+ // ensure that the child txn has the correct transfer acct payee
217
+ child = await db.getTransaction(child.id);
218
+ expect(child.transfer_id).not.toBe(parent.transfer_id);
219
+ expect(child.payee).toBe(transferOne.id);
220
+ });
221
+ });
@@ -0,0 +1,173 @@
1
+ // @ts-strict-ignore
2
+ import * as db from '../db';
3
+
4
+ import { runRules } from './transaction-rules';
5
+
6
+ async function getPayee(acct) {
7
+ return db.first<db.DbPayee>('SELECT * FROM payees WHERE transfer_acct = ?', [
8
+ acct,
9
+ ]);
10
+ }
11
+
12
+ async function getTransferredAccount(transaction) {
13
+ if (transaction.payee) {
14
+ const result = await db.first<Pick<db.DbViewPayee, 'transfer_acct'>>(
15
+ 'SELECT transfer_acct FROM v_payees WHERE id = ?',
16
+ [transaction.payee],
17
+ );
18
+
19
+ return result?.transfer_acct || null;
20
+ }
21
+ return null;
22
+ }
23
+
24
+ async function clearCategory(transaction, transferAcct) {
25
+ const { offbudget: fromOffBudget } = await db.first<
26
+ Pick<db.DbAccount, 'offbudget'>
27
+ >('SELECT offbudget FROM accounts WHERE id = ?', [transaction.account]);
28
+ const { offbudget: toOffBudget } = await db.first<
29
+ Pick<db.DbAccount, 'offbudget'>
30
+ >('SELECT offbudget FROM accounts WHERE id = ?', [transferAcct]);
31
+
32
+ // If the transfer is between two on budget or two off budget accounts,
33
+ // we should clear the category, because the category is not relevant
34
+ if (fromOffBudget === toOffBudget) {
35
+ await db.updateTransaction({ id: transaction.id, category: null });
36
+ if (transaction.transfer_id) {
37
+ await db.updateTransaction({
38
+ id: transaction.transfer_id,
39
+ category: null,
40
+ });
41
+ }
42
+ return true;
43
+ }
44
+ return false;
45
+ }
46
+
47
+ export async function addTransfer(transaction, transferredAccount) {
48
+ if (transaction.is_parent) {
49
+ // For split transactions, we should create transfers using child transactions.
50
+ // This is to ensure that the amounts received by the transferred account
51
+ // reflects the amounts in the child transactions and not the parent transaction
52
+ // amount which is the total amount.
53
+ return null;
54
+ }
55
+
56
+ const { id: fromPayee } = await db.first<Pick<db.DbPayee, 'id'>>(
57
+ 'SELECT id FROM payees WHERE transfer_acct = ?',
58
+ [transaction.account],
59
+ );
60
+
61
+ const transferTransaction = {
62
+ account: transferredAccount,
63
+ amount: -transaction.amount,
64
+ payee: fromPayee,
65
+ date: transaction.date,
66
+ transfer_id: transaction.id,
67
+ notes: transaction.notes || null,
68
+ schedule: transaction.schedule,
69
+ cleared: false,
70
+ };
71
+ const { notes, cleared, schedule } = await runRules(transferTransaction);
72
+ const matchedSchedule = schedule ?? transaction.schedule;
73
+
74
+ const id = await db.insertTransaction({
75
+ ...transferTransaction,
76
+ notes,
77
+ cleared,
78
+ schedule: matchedSchedule,
79
+ });
80
+
81
+ await db.updateTransaction({
82
+ id: transaction.id,
83
+ transfer_id: id,
84
+ ...(matchedSchedule ? { schedule: matchedSchedule } : {}),
85
+ });
86
+ const categoryCleared = await clearCategory(transaction, transferredAccount);
87
+
88
+ return {
89
+ id: transaction.id,
90
+ transfer_id: id,
91
+ ...(categoryCleared ? { category: null } : {}),
92
+ };
93
+ }
94
+
95
+ export async function removeTransfer(transaction) {
96
+ const transferTrans = await db.getTransaction(transaction.transfer_id);
97
+
98
+ // Perform operations on the transfer transaction only
99
+ // if it is found. For example: when users delete both
100
+ // (in & out) transfer transactions at the same time -
101
+ // transfer transaction will not be found.
102
+ if (transferTrans) {
103
+ if (transferTrans.is_child) {
104
+ // If it's a child transaction, we don't delete it because that
105
+ // would invalidate the whole split transaction. Instead of turn
106
+ // it into a normal transaction
107
+ await db.updateTransaction({
108
+ id: transaction.transfer_id,
109
+ transfer_id: null,
110
+ payee: null,
111
+ });
112
+ } else {
113
+ await db.deleteTransaction({ id: transaction.transfer_id });
114
+ }
115
+ }
116
+ await db.updateTransaction({ id: transaction.id, transfer_id: null });
117
+ return { id: transaction.id, transfer_id: null };
118
+ }
119
+
120
+ export async function updateTransfer(transaction, transferredAccount) {
121
+ const payee = await getPayee(transaction.account);
122
+
123
+ await db.updateTransaction({
124
+ id: transaction.transfer_id,
125
+ account: transferredAccount,
126
+ // Make sure to update the payee on the other side in case the
127
+ // user moved this transaction into another account
128
+ payee: payee.id,
129
+ date: transaction.date,
130
+ notes: transaction.notes,
131
+ amount: -transaction.amount,
132
+ schedule: transaction.schedule,
133
+ });
134
+
135
+ const categoryCleared = await clearCategory(transaction, transferredAccount);
136
+ if (categoryCleared) {
137
+ return { id: transaction.id, category: null };
138
+ }
139
+ }
140
+
141
+ export async function onInsert(transaction) {
142
+ const transferredAccount = await getTransferredAccount(transaction);
143
+
144
+ if (transferredAccount) {
145
+ return addTransfer(transaction, transferredAccount);
146
+ }
147
+ }
148
+
149
+ export async function onDelete(transaction) {
150
+ if (transaction.transfer_id) {
151
+ await removeTransfer(transaction);
152
+ }
153
+ }
154
+
155
+ export async function onUpdate(transaction) {
156
+ const transferredAccount = await getTransferredAccount(transaction);
157
+
158
+ if (transaction.is_parent) {
159
+ return removeTransfer(transaction);
160
+ }
161
+
162
+ if (transferredAccount && !transaction.transfer_id) {
163
+ return addTransfer(transaction, transferredAccount);
164
+ }
165
+
166
+ if (!transferredAccount && transaction.transfer_id) {
167
+ return removeTransfer(transaction);
168
+ }
169
+
170
+ if (transferredAccount && transaction.transfer_id) {
171
+ return updateTransfer(transaction, transferredAccount);
172
+ }
173
+ }
@@ -0,0 +1,271 @@
1
+ // @ts-strict-ignore
2
+ import { Timestamp } from '@actual-app/crdt';
3
+
4
+ import * as connection from '../platform/server/connection';
5
+ import { getIn } from '../shared/util';
6
+ import type { HandlerFunctions } from '../types/handlers';
7
+
8
+ import { getMutatorContext, withMutatorContext } from './mutators';
9
+ import { sendMessages } from './sync';
10
+ import type { Message } from './sync';
11
+
12
+ // A marker always sits as the first entry to simplify logic
13
+ type MarkerMessage = { type: 'marker'; meta?: unknown };
14
+ type MessagesMessage = {
15
+ type: 'messages';
16
+ messages: Message[];
17
+ meta?: unknown;
18
+ oldData;
19
+ undoTag;
20
+ };
21
+ let MESSAGE_HISTORY: Array<MarkerMessage | MessagesMessage> = [
22
+ { type: 'marker' },
23
+ ];
24
+ let CURSOR = 0;
25
+ const HISTORY_SIZE = 20;
26
+
27
+ export type UndoState = {
28
+ messages: Message[];
29
+ meta?: unknown;
30
+ tables: string[];
31
+ undoTag: string;
32
+ };
33
+
34
+ function trimHistory() {
35
+ MESSAGE_HISTORY = MESSAGE_HISTORY.slice(0, CURSOR + 1);
36
+
37
+ const markers = MESSAGE_HISTORY.filter(item => item.type === 'marker');
38
+ if (markers.length > HISTORY_SIZE) {
39
+ const slice = markers.slice(-HISTORY_SIZE);
40
+ const cutoff = MESSAGE_HISTORY.indexOf(slice[0]);
41
+ MESSAGE_HISTORY = MESSAGE_HISTORY.slice(cutoff);
42
+ CURSOR = MESSAGE_HISTORY.length - 1;
43
+ }
44
+ }
45
+
46
+ export function appendMessages(messages, oldData) {
47
+ const context = getMutatorContext();
48
+
49
+ if (context.undoListening && messages.length > 0) {
50
+ trimHistory();
51
+
52
+ const { undoTag } = context;
53
+
54
+ MESSAGE_HISTORY.push({
55
+ type: 'messages',
56
+ messages,
57
+ oldData,
58
+ undoTag,
59
+ });
60
+ CURSOR++;
61
+ }
62
+ }
63
+
64
+ export function clearUndo() {
65
+ MESSAGE_HISTORY = [{ type: 'marker' }];
66
+ CURSOR = 0;
67
+ }
68
+
69
+ export function withUndo<T>(
70
+ func: () => Promise<T>,
71
+ meta?: unknown,
72
+ ): Promise<T> {
73
+ const context = getMutatorContext();
74
+ if (context.undoDisabled || context.undoListening) {
75
+ return func();
76
+ }
77
+
78
+ MESSAGE_HISTORY = MESSAGE_HISTORY.slice(0, CURSOR + 1);
79
+
80
+ const marker: MarkerMessage = { type: 'marker', meta };
81
+
82
+ if (MESSAGE_HISTORY[MESSAGE_HISTORY.length - 1].type === 'marker') {
83
+ MESSAGE_HISTORY[MESSAGE_HISTORY.length - 1] = marker;
84
+ } else {
85
+ MESSAGE_HISTORY.push(marker);
86
+ CURSOR++;
87
+ }
88
+
89
+ return withMutatorContext(
90
+ { undoListening: true, undoTag: context.undoTag },
91
+ func,
92
+ );
93
+ }
94
+
95
+ export function undoable<T extends HandlerFunctions>(
96
+ func: T,
97
+ metaFunc?: (...metaArgs: Parameters<T>) => unknown,
98
+ ) {
99
+ return (...args: Parameters<T>) => {
100
+ return withUndo<Awaited<ReturnType<T>>>(
101
+ () => {
102
+ return func.apply(null, args);
103
+ },
104
+ metaFunc ? metaFunc(...args) : undefined,
105
+ );
106
+ };
107
+ }
108
+
109
+ async function applyUndoAction(messages, meta, undoTag) {
110
+ await withMutatorContext({ undoListening: false }, () => {
111
+ return sendMessages(
112
+ messages.map(msg => ({ ...msg, timestamp: Timestamp.send() })),
113
+ );
114
+ });
115
+
116
+ const tables = messages.reduce((acc, message) => {
117
+ if (!acc.includes(message.dataset)) {
118
+ acc.push(message.dataset);
119
+ }
120
+ return acc;
121
+ }, []);
122
+
123
+ connection.send('undo-event', {
124
+ messages,
125
+ tables,
126
+ meta,
127
+ undoTag,
128
+ });
129
+ }
130
+
131
+ export async function undo() {
132
+ const end = CURSOR;
133
+ CURSOR = Math.max(CURSOR - 1, 0);
134
+
135
+ // Walk back to the nearest marker
136
+ while (CURSOR > 0 && MESSAGE_HISTORY[CURSOR].type !== 'marker') {
137
+ CURSOR--;
138
+ }
139
+
140
+ const meta = MESSAGE_HISTORY[CURSOR].meta;
141
+ const start = Math.max(CURSOR, 0);
142
+ const entries = MESSAGE_HISTORY.slice(start, end + 1).filter(
143
+ (entry): entry is MessagesMessage => entry.type === 'messages',
144
+ );
145
+
146
+ if (entries.length > 0) {
147
+ const toApply = entries
148
+ .reduce((acc, entry) => {
149
+ return acc.concat(
150
+ entry.messages
151
+ .map(message => undoMessage(message, entry.oldData))
152
+ .filter(x => x),
153
+ );
154
+ }, [])
155
+ .reverse();
156
+
157
+ await applyUndoAction(toApply, meta, entries[0].undoTag);
158
+ }
159
+ }
160
+
161
+ function undoMessage(message, oldData) {
162
+ const oldItem = getIn(oldData, [message.dataset, message.row]);
163
+ if (oldItem) {
164
+ let column = message.column;
165
+ if (message.dataset === 'spreadsheet_cells') {
166
+ // The spreadsheet messages use the `expr` column, but only as a
167
+ // placeholder. We actually want to read the `cachedValue` prop
168
+ // from the old item.
169
+ column = 'cachedValue';
170
+ }
171
+
172
+ return { ...message, value: oldItem[column] };
173
+ } else {
174
+ if (message.dataset === 'spreadsheet_cells') {
175
+ if (message.column === 'expr') {
176
+ return { ...message, value: null };
177
+ }
178
+ return message;
179
+ } else if (
180
+ // The mapping fields aren't ever deleted... this should be
181
+ // harmless since all they are is meta information. Maybe we
182
+ // should fix this though.
183
+ message.dataset !== 'category_mapping' &&
184
+ message.dataset !== 'payee_mapping'
185
+ ) {
186
+ if (
187
+ message.dataset === 'zero_budget_months' ||
188
+ message.dataset === 'zero_budgets' ||
189
+ message.dataset === 'reflect_budgets'
190
+ ) {
191
+ // Only these fields are reversable
192
+ if (['buffered', 'amount', 'carryover'].includes(message.column)) {
193
+ return { ...message, value: 0 };
194
+ }
195
+ return null;
196
+ } else if (message.dataset === 'notes') {
197
+ return { ...message, value: null };
198
+ }
199
+
200
+ return { ...message, column: 'tombstone', value: 1 };
201
+ }
202
+ }
203
+ return null;
204
+ }
205
+
206
+ export async function redo() {
207
+ const meta =
208
+ MESSAGE_HISTORY[CURSOR].type === 'marker'
209
+ ? MESSAGE_HISTORY[CURSOR].meta
210
+ : null;
211
+
212
+ const start = CURSOR;
213
+ CURSOR = Math.min(CURSOR + 1, MESSAGE_HISTORY.length - 1);
214
+
215
+ // Walk forward to the nearest marker
216
+ while (
217
+ CURSOR < MESSAGE_HISTORY.length - 1 &&
218
+ MESSAGE_HISTORY[CURSOR].type !== 'marker'
219
+ ) {
220
+ CURSOR++;
221
+ }
222
+
223
+ const end = CURSOR;
224
+ const entries = MESSAGE_HISTORY.slice(start + 1, end + 1).filter(
225
+ (entry): entry is MessagesMessage => entry.type === 'messages',
226
+ );
227
+
228
+ if (entries.length > 0) {
229
+ const toApply = entries.reduce((acc, entry) => {
230
+ return acc
231
+ .concat(entry.messages)
232
+ .concat(redoResurrections(entry.messages, entry.oldData));
233
+ }, []);
234
+
235
+ await applyUndoAction(toApply, meta, entries[entries.length - 1].undoTag);
236
+ }
237
+ }
238
+
239
+ function redoResurrections(messages, oldData): Message[] {
240
+ const resurrect = new Set<string>();
241
+
242
+ messages.forEach(message => {
243
+ // If any of the ids didn't exist before, we need to "resurrect"
244
+ // them by resetting their tombstones to 0
245
+ const oldItem = getIn(oldData, [message.dataset, message.row]);
246
+ if (
247
+ !oldItem &&
248
+ ![
249
+ 'zero_budget_months',
250
+ 'zero_budgets',
251
+ 'reflect_budgets',
252
+ 'notes',
253
+ 'category_mapping',
254
+ 'payee_mapping',
255
+ ].includes(message.dataset)
256
+ ) {
257
+ resurrect.add(message.dataset + '.' + message.row);
258
+ }
259
+ });
260
+
261
+ return [...resurrect].map(desc => {
262
+ const [table, row] = desc.split('.');
263
+ return {
264
+ dataset: table,
265
+ row,
266
+ column: 'tombstone',
267
+ value: 0,
268
+ timestamp: Timestamp.send(),
269
+ };
270
+ });
271
+ }
@@ -0,0 +1,37 @@
1
+ // @ts-strict-ignore
2
+ import md5 from 'md5';
3
+
4
+ import { makeViews, schema, schemaConfig } from './aql';
5
+ import * as db from './db';
6
+ import * as migrations from './migrate/migrations';
7
+
8
+ // Managing the init/update process
9
+
10
+ async function runMigrations() {
11
+ await migrations.migrate(db.getDatabase());
12
+ }
13
+
14
+ async function updateViews() {
15
+ const hashKey = 'view-hash';
16
+ const row = await db.first<{ value: string }>(
17
+ 'SELECT value FROM __meta__ WHERE key = ?',
18
+ [hashKey],
19
+ );
20
+ const { value: hash } = row || {};
21
+
22
+ const views = makeViews(schema, schemaConfig);
23
+ const currentHash = md5(views);
24
+
25
+ if (hash !== currentHash) {
26
+ db.execQuery(views);
27
+ db.runQuery('INSERT OR REPLACE INTO __meta__ (key, value) VALUES (?, ?)', [
28
+ hashKey,
29
+ currentHash,
30
+ ]);
31
+ }
32
+ }
33
+
34
+ export async function updateVersion() {
35
+ await runMigrations();
36
+ await updateViews();
37
+ }