@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.
- package/.swcrc +11 -0
- package/bin/build-browser +40 -0
- package/bin/copy-migrations +9 -0
- package/db.sqlite +0 -0
- package/default-db.sqlite +0 -0
- package/migrations/.force-copy-windows +0 -0
- package/migrations/1548957970627_remove-db-version.sql +5 -0
- package/migrations/1550601598648_payees.sql +23 -0
- package/migrations/1555786194328_remove_category_group_unique.sql +25 -0
- package/migrations/1561751833510_indexes.sql +7 -0
- package/migrations/1567699552727_budget.sql +38 -0
- package/migrations/1582384163573_cleared.sql +6 -0
- package/migrations/1597756566448_rules.sql +10 -0
- package/migrations/1608652596043_parent_field.sql +13 -0
- package/migrations/1608652596044_trans_views.sql +56 -0
- package/migrations/1612625548236_optimize.sql +7 -0
- package/migrations/1614782639336_trans_views2.sql +33 -0
- package/migrations/1615745967948_meta.sql +10 -0
- package/migrations/1616167010796_accounts_order.sql +5 -0
- package/migrations/1618975177358_schedules.sql +28 -0
- package/migrations/1632571489012_remove_cache.js +136 -0
- package/migrations/1679728867040_rules_conditions.sql +5 -0
- package/migrations/1681115033845_add_schedule_name.sql +5 -0
- package/migrations/1682974838138_remove_payee_rules.sql +5 -0
- package/migrations/1685007876842_add_category_hidden.sql +6 -0
- package/migrations/1686139660866_remove_account_type.sql +5 -0
- package/migrations/1688749527273_transaction_filters.sql +10 -0
- package/migrations/1688841238000_add_account_type.sql +5 -0
- package/migrations/1691233396000_add_schedule_next_date_tombstone.sql +5 -0
- package/migrations/1694438752000_add_goal_targets.sql +7 -0
- package/migrations/1697046240000_add_reconciled.sql +5 -0
- package/migrations/1704572023730_add_account_sync_source.sql +5 -0
- package/migrations/1704572023731_add_missing_goCardless_sync_source.sql +9 -0
- package/migrations/1707267033000_reports.sql +28 -0
- package/migrations/1712784523000_unhide_input_group.sql +8 -0
- package/migrations/1716359441000_include_current.sql +5 -0
- package/migrations/1720310586000_link_transfer_schedules.sql +19 -0
- package/migrations/1720664867241_add_payee_favorite.sql +5 -0
- package/migrations/1720665000000_goal_context.sql +6 -0
- package/migrations/1722717601000_reports_move_selected_categories.js +55 -0
- package/migrations/1722804019000_create_dashboard_table.js +69 -0
- package/migrations/1723665565000_prefs.js +59 -0
- package/migrations/1730744182000_fix_dashboard_table.sql +7 -0
- package/migrations/1736640000000_custom_report_sorting.sql +7 -0
- package/migrations/1737158400000_add_learn_categories_to_payees.sql +5 -0
- package/migrations/1738491452000_sorting_rename.sql +13 -0
- package/migrations/1739139550000_bank_sync_page.sql +7 -0
- package/migrations/1740506588539_add_last_reconciled_at.sql +5 -0
- package/migrations/1745425408000_update_budgetType_pref.sql +7 -0
- package/migrations/1749799110000_add_tags.sql +10 -0
- package/migrations/1749799110001_tags_tombstone.sql +5 -0
- package/migrations/1754611200000_add_category_template_settings.sql +5 -0
- package/migrations/1759260219000_add_trim_interval_report_setting.sql +6 -0
- package/migrations/1759842823172_add_isGlobal_to_preferences.sql +1 -0
- package/migrations/1762178745667_rename_csv_skip_lines_pref.sql +8 -0
- package/migrations/1765518577215_multiple_dashboards.js +30 -0
- package/migrations/1768872504000_add_payee_locations.sql +21 -0
- package/package.json +128 -0
- package/src/mocks/arbitrary-schema.ts +162 -0
- package/src/mocks/budget.ts +901 -0
- package/src/mocks/files/8859-1.qfx +63 -0
- package/src/mocks/files/best.data-ever$.QFX +124 -0
- package/src/mocks/files/big.data.QiF +91 -0
- package/src/mocks/files/budgets/.commit-to-git +0 -0
- package/src/mocks/files/camt/camt.053.payee-memo.xml +127 -0
- package/src/mocks/files/camt/camt.053.xml +463 -0
- package/src/mocks/files/credit-card.ofx +11 -0
- package/src/mocks/files/data-multi-decimal.ofx +64 -0
- package/src/mocks/files/data-payee-memo.ofx +75 -0
- package/src/mocks/files/data-payee-memo.qif +17 -0
- package/src/mocks/files/data.ofx +124 -0
- package/src/mocks/files/data.qfx +124 -0
- package/src/mocks/files/data.qif +91 -0
- package/src/mocks/files/default-budget-template/db.sqlite +0 -0
- package/src/mocks/files/default-budget-template/metadata.json +6 -0
- package/src/mocks/files/html-vals.qfx +17 -0
- package/src/mocks/index.ts +221 -0
- package/src/mocks/migrations/1508717984291_up_add-poop.sql +13 -0
- package/src/mocks/migrations/1508718036311_up_modify-poop.sql +2 -0
- package/src/mocks/migrations/1508727787513_remove-is_income.sql +15 -0
- package/src/mocks/random.ts +16 -0
- package/src/mocks/setup.ts +180 -0
- package/src/mocks/spreadsheet.ts +101 -0
- package/src/mocks/util.ts +82 -0
- package/src/platform/client/connection/README.md +3 -0
- package/src/platform/client/connection/__mocks__/index.ts +67 -0
- package/src/platform/client/connection/index-types.ts +95 -0
- package/src/platform/client/connection/index.browser.ts +213 -0
- package/src/platform/client/connection/index.ts +155 -0
- package/src/platform/client/undo/index.ts +59 -0
- package/src/platform/exceptions/__mocks__/index.ts +7 -0
- package/src/platform/exceptions/index.ts +9 -0
- package/src/platform/server/asyncStorage/__mocks__/index.ts +50 -0
- package/src/platform/server/asyncStorage/index-types.ts +35 -0
- package/src/platform/server/asyncStorage/index.api.ts +2 -0
- package/src/platform/server/asyncStorage/index.electron.ts +88 -0
- package/src/platform/server/asyncStorage/index.ts +126 -0
- package/src/platform/server/connection/README.md +3 -0
- package/src/platform/server/connection/__mocks__/index.ts +15 -0
- package/src/platform/server/connection/index-types.ts +20 -0
- package/src/platform/server/connection/index.api.ts +13 -0
- package/src/platform/server/connection/index.electron.ts +102 -0
- package/src/platform/server/connection/index.ts +154 -0
- package/src/platform/server/fetch/__mocks__/index.ts +3 -0
- package/src/platform/server/fetch/index.api.ts +1 -0
- package/src/platform/server/fetch/index.electron.ts +18 -0
- package/src/platform/server/fetch/index.ts +20 -0
- package/src/platform/server/fs/index.api.ts +198 -0
- package/src/platform/server/fs/index.electron.ts +208 -0
- package/src/platform/server/fs/index.test.ts +117 -0
- package/src/platform/server/fs/index.ts +416 -0
- package/src/platform/server/fs/path-join.api.ts +1 -0
- package/src/platform/server/fs/path-join.electron.ts +1 -0
- package/src/platform/server/fs/path-join.ts +97 -0
- package/src/platform/server/fs/shared.ts +33 -0
- package/src/platform/server/indexeddb/index.ts +115 -0
- package/src/platform/server/log/index.ts +43 -0
- package/src/platform/server/sqlite/index.api.ts +2 -0
- package/src/platform/server/sqlite/index.electron.ts +134 -0
- package/src/platform/server/sqlite/index.test.ts +108 -0
- package/src/platform/server/sqlite/index.ts +241 -0
- package/src/platform/server/sqlite/normalise.ts +9 -0
- package/src/platform/server/sqlite/unicodeLike.test.ts +58 -0
- package/src/platform/server/sqlite/unicodeLike.ts +31 -0
- package/src/server/__mocks__/post.ts +9 -0
- package/src/server/__snapshots__/main.test.ts.snap +199 -0
- package/src/server/__snapshots__/sheet.test.ts.snap +9 -0
- package/src/server/accounts/__snapshots__/sync.test.ts.snap +136 -0
- package/src/server/accounts/app-bank-sync.test.ts +136 -0
- package/src/server/accounts/app.ts +1294 -0
- package/src/server/accounts/link.ts +25 -0
- package/src/server/accounts/payees.ts +36 -0
- package/src/server/accounts/sync.test.ts +679 -0
- package/src/server/accounts/sync.ts +1168 -0
- package/src/server/accounts/title/index.ts +60 -0
- package/src/server/accounts/title/lower-case.ts +93 -0
- package/src/server/accounts/title/specials.ts +21 -0
- package/src/server/admin/app.ts +241 -0
- package/src/server/api-models.ts +244 -0
- package/src/server/api.test.ts +36 -0
- package/src/server/api.ts +1030 -0
- package/src/server/app.ts +91 -0
- package/src/server/aql/compiler.test.ts +966 -0
- package/src/server/aql/compiler.ts +1222 -0
- package/src/server/aql/exec.test.ts +289 -0
- package/src/server/aql/exec.ts +128 -0
- package/src/server/aql/index.ts +41 -0
- package/src/server/aql/schema/executors.test.ts +420 -0
- package/src/server/aql/schema/executors.ts +345 -0
- package/src/server/aql/schema/index.test.ts +67 -0
- package/src/server/aql/schema/index.ts +409 -0
- package/src/server/aql/schema-helpers.test.ts +242 -0
- package/src/server/aql/schema-helpers.ts +208 -0
- package/src/server/aql/views.test.ts +62 -0
- package/src/server/aql/views.ts +57 -0
- package/src/server/auth/app.ts +387 -0
- package/src/server/bench.ts +29 -0
- package/src/server/budget/actions.ts +686 -0
- package/src/server/budget/app.ts +469 -0
- package/src/server/budget/base.test.ts +340 -0
- package/src/server/budget/base.ts +339 -0
- package/src/server/budget/category-template-context.test.ts +1658 -0
- package/src/server/budget/category-template-context.ts +862 -0
- package/src/server/budget/cleanup-template.pegjs +27 -0
- package/src/server/budget/cleanup-template.ts +408 -0
- package/src/server/budget/envelope.ts +403 -0
- package/src/server/budget/goal-template.pegjs +110 -0
- package/src/server/budget/goal-template.ts +309 -0
- package/src/server/budget/report.ts +308 -0
- package/src/server/budget/schedule-template.test.ts +184 -0
- package/src/server/budget/schedule-template.ts +351 -0
- package/src/server/budget/statements.ts +60 -0
- package/src/server/budget/template-notes.test.ts +393 -0
- package/src/server/budget/template-notes.ts +323 -0
- package/src/server/budget/util.ts +25 -0
- package/src/server/budgetfiles/__snapshots__/backups.test.ts.snap +101 -0
- package/src/server/budgetfiles/app.ts +672 -0
- package/src/server/budgetfiles/backups.test.ts +79 -0
- package/src/server/budgetfiles/backups.ts +251 -0
- package/src/server/cloud-storage.ts +467 -0
- package/src/server/dashboard/app.ts +373 -0
- package/src/server/db/__snapshots__/index.test.ts.snap +271 -0
- package/src/server/db/index.test.ts +300 -0
- package/src/server/db/index.ts +855 -0
- package/src/server/db/mappings.ts +59 -0
- package/src/server/db/sort.ts +58 -0
- package/src/server/db/types/index.ts +342 -0
- package/src/server/db/util.ts +36 -0
- package/src/server/encryption/app.ts +133 -0
- package/src/server/encryption/encryption-internals.api.ts +2 -0
- package/src/server/encryption/encryption-internals.electron.ts +89 -0
- package/src/server/encryption/encryption-internals.ts +109 -0
- package/src/server/encryption/encryption.test.ts +19 -0
- package/src/server/encryption/index.test.ts +19 -0
- package/src/server/encryption/index.ts +89 -0
- package/src/server/errors.ts +110 -0
- package/src/server/filters/app.ts +191 -0
- package/src/server/importers/actual.ts +49 -0
- package/src/server/importers/index.ts +58 -0
- package/src/server/importers/ynab4-types.ts +163 -0
- package/src/server/importers/ynab4.ts +470 -0
- package/src/server/importers/ynab5-types.ts +290 -0
- package/src/server/importers/ynab5.ts +1193 -0
- package/src/server/main-app.ts +25 -0
- package/src/server/main.test.ts +392 -0
- package/src/server/main.ts +336 -0
- package/src/server/migrate/__snapshots__/migrations.test.ts.snap +17 -0
- package/src/server/migrate/cli.ts +100 -0
- package/src/server/migrate/migrations.test.ts +81 -0
- package/src/server/migrate/migrations.ts +192 -0
- package/src/server/models.ts +184 -0
- package/src/server/mutators.ts +139 -0
- package/src/server/notes/app.ts +18 -0
- package/src/server/payees/app.ts +351 -0
- package/src/server/polyfills.ts +26 -0
- package/src/server/post.ts +219 -0
- package/src/server/preferences/app.ts +249 -0
- package/src/server/prefs.ts +91 -0
- package/src/server/reports/app.ts +187 -0
- package/src/server/rules/action.ts +344 -0
- package/src/server/rules/app.ts +193 -0
- package/src/server/rules/condition.ts +436 -0
- package/src/server/rules/customFunctions.ts +61 -0
- package/src/server/rules/formula-action.test.ts +175 -0
- package/src/server/rules/handlebars-helpers.ts +131 -0
- package/src/server/rules/index.test.ts +1095 -0
- package/src/server/rules/index.ts +22 -0
- package/src/server/rules/rule-indexer.ts +89 -0
- package/src/server/rules/rule-utils.ts +274 -0
- package/src/server/rules/rule.ts +193 -0
- package/src/server/schedules/app.test.ts +502 -0
- package/src/server/schedules/app.ts +644 -0
- package/src/server/schedules/find-schedules.ts +391 -0
- package/src/server/server-config.ts +59 -0
- package/src/server/sheet.test.ts +101 -0
- package/src/server/sheet.ts +280 -0
- package/src/server/spreadsheet/__snapshots__/spreadsheet.test.ts.snap +5 -0
- package/src/server/spreadsheet/app.ts +54 -0
- package/src/server/spreadsheet/globals.ts +13 -0
- package/src/server/spreadsheet/graph-data-structure.ts +165 -0
- package/src/server/spreadsheet/scratch +60 -0
- package/src/server/spreadsheet/spreadsheet.test.ts +191 -0
- package/src/server/spreadsheet/spreadsheet.ts +523 -0
- package/src/server/spreadsheet/util.ts +15 -0
- package/src/server/sql/init.sql +88 -0
- package/src/server/sync/__snapshots__/sync.test.ts.snap +31 -0
- package/src/server/sync/app.ts +29 -0
- package/src/server/sync/encoder.ts +129 -0
- package/src/server/sync/index.ts +820 -0
- package/src/server/sync/make-test-message.ts +19 -0
- package/src/server/sync/migrate.test.ts +169 -0
- package/src/server/sync/migrate.ts +48 -0
- package/src/server/sync/repair.ts +39 -0
- package/src/server/sync/reset.ts +91 -0
- package/src/server/sync/sync.property.test.ts +385 -0
- package/src/server/sync/sync.test.ts +349 -0
- package/src/server/sync/utils.ts +3 -0
- package/src/server/tags/app.ts +101 -0
- package/src/server/tests/mockData.json +9352 -0
- package/src/server/tests/mockSyncServer.ts +119 -0
- package/src/server/tools/app.ts +152 -0
- package/src/server/transactions/__snapshots__/transaction-rules.test.ts.snap +173 -0
- package/src/server/transactions/__snapshots__/transfer.test.ts.snap +655 -0
- package/src/server/transactions/app.ts +136 -0
- package/src/server/transactions/export/export-to-csv.ts +132 -0
- package/src/server/transactions/import/__snapshots__/parse-file.test.ts.snap +1582 -0
- package/src/server/transactions/import/ofx2json.test.ts +33 -0
- package/src/server/transactions/import/ofx2json.ts +157 -0
- package/src/server/transactions/import/parse-file.test.ts +224 -0
- package/src/server/transactions/import/parse-file.ts +286 -0
- package/src/server/transactions/import/qif2json.ts +110 -0
- package/src/server/transactions/import/xmlcamt2json.ts +168 -0
- package/src/server/transactions/index.ts +196 -0
- package/src/server/transactions/merge.test.ts +370 -0
- package/src/server/transactions/merge.ts +139 -0
- package/src/server/transactions/transaction-rules.test.ts +994 -0
- package/src/server/transactions/transaction-rules.ts +1038 -0
- package/src/server/transactions/transfer.test.ts +221 -0
- package/src/server/transactions/transfer.ts +173 -0
- package/src/server/undo.ts +271 -0
- package/src/server/update.ts +37 -0
- package/src/server/util/budget-name.ts +61 -0
- package/src/server/util/custom-sync-mapping.ts +48 -0
- package/src/server/util/rschedule.ts +9 -0
- package/src/shared/__mocks__/platform.ts +7 -0
- package/src/shared/__snapshots__/months.test.ts.snap +21 -0
- package/src/shared/arithmetic.test.ts +112 -0
- package/src/shared/arithmetic.ts +170 -0
- package/src/shared/async.test.ts +135 -0
- package/src/shared/async.ts +76 -0
- package/src/shared/constants.ts +5 -0
- package/src/shared/currencies.ts +70 -0
- package/src/shared/dashboard.ts +260 -0
- package/src/shared/environment.ts +18 -0
- package/src/shared/errors.ts +195 -0
- package/src/shared/locale.ts +27 -0
- package/src/shared/location-utils.test.ts +69 -0
- package/src/shared/location-utils.ts +49 -0
- package/src/shared/months.test.ts +5 -0
- package/src/shared/months.ts +485 -0
- package/src/shared/normalisation.ts +6 -0
- package/src/shared/platform.electron.ts +21 -0
- package/src/shared/platform.ts +20 -0
- package/src/shared/query.ts +176 -0
- package/src/shared/rules.test.ts +56 -0
- package/src/shared/rules.ts +371 -0
- package/src/shared/schedules.test.ts +570 -0
- package/src/shared/schedules.ts +560 -0
- package/src/shared/test-helpers.ts +156 -0
- package/src/shared/transactions.test.ts +275 -0
- package/src/shared/transactions.ts +433 -0
- package/src/shared/transfer.test.ts +75 -0
- package/src/shared/transfer.ts +16 -0
- package/src/shared/user.ts +4 -0
- package/src/shared/util.test.ts +240 -0
- package/src/shared/util.ts +633 -0
- package/src/types/api-handlers.ts +287 -0
- package/src/types/budget.ts +8 -0
- package/src/types/file.ts +47 -0
- package/src/types/handlers.ts +46 -0
- package/src/types/models/account.ts +24 -0
- package/src/types/models/bank-sync.ts +23 -0
- package/src/types/models/bank.ts +6 -0
- package/src/types/models/category-group.ts +11 -0
- package/src/types/models/category.ts +13 -0
- package/src/types/models/dashboard.ts +199 -0
- package/src/types/models/gocardless.ts +84 -0
- package/src/types/models/import-transaction.ts +56 -0
- package/src/types/models/index.ts +23 -0
- package/src/types/models/nearby-payee.ts +7 -0
- package/src/types/models/note.ts +4 -0
- package/src/types/models/openid.ts +8 -0
- package/src/types/models/payee-location.ts +8 -0
- package/src/types/models/payee.ts +10 -0
- package/src/types/models/pluggyai.ts +19 -0
- package/src/types/models/reports.ts +144 -0
- package/src/types/models/rule.ts +174 -0
- package/src/types/models/schedule.ts +49 -0
- package/src/types/models/simplefin.ts +28 -0
- package/src/types/models/tags.ts +6 -0
- package/src/types/models/templates.ts +135 -0
- package/src/types/models/transaction-filter.ts +9 -0
- package/src/types/models/transaction.ts +39 -0
- package/src/types/models/user-access.ts +10 -0
- package/src/types/models/user.ts +25 -0
- package/src/types/prefs.ts +167 -0
- package/src/types/server-events.ts +86 -0
- package/src/types/server-handlers.ts +27 -0
- package/src/types/util.ts +26 -0
- package/tsconfig.json +34 -0
- package/typings/pegjs.ts +1 -0
- package/typings/process-worker.ts +12 -0
- package/typings/vite-plugin-peggy-loader.ts +1 -0
- package/typings/window.ts +62 -0
- package/vite.config.ts +109 -0
- package/vite.desktop.config.ts +59 -0
- package/vitest.config.ts +43 -0
- package/vitest.web.config.ts +38 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// @ts-strict-ignore
|
|
2
|
+
import type { TransactionEntity } from '../../types/models';
|
|
3
|
+
import * as db from '../db';
|
|
4
|
+
|
|
5
|
+
import { mergeTransactions } from './merge';
|
|
6
|
+
|
|
7
|
+
describe('Merging fails for invalid quantity', () => {
|
|
8
|
+
beforeEach(global.emptyDatabase());
|
|
9
|
+
afterEach(global.emptyDatabase());
|
|
10
|
+
|
|
11
|
+
const tests: [TransactionEntity[], string][] = [
|
|
12
|
+
[[{} as TransactionEntity], 'one transaction'],
|
|
13
|
+
[[], 'no transactions'],
|
|
14
|
+
[undefined as unknown as TransactionEntity[], 'undefined'],
|
|
15
|
+
[[{}, {}, {}] as TransactionEntity[], 'three transactions'],
|
|
16
|
+
[
|
|
17
|
+
[{}, undefined] as TransactionEntity[],
|
|
18
|
+
'two transactions but one is undefined',
|
|
19
|
+
],
|
|
20
|
+
];
|
|
21
|
+
tests.forEach(([arr, message]) =>
|
|
22
|
+
it(message, () => expect(() => mergeTransactions(arr)).rejects.toThrow()),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
it("fails when amounts don't match", async () => {
|
|
26
|
+
await prepareDatabase();
|
|
27
|
+
const t1 = await db.insertTransaction({
|
|
28
|
+
account: 'one',
|
|
29
|
+
date: '2025-01-01',
|
|
30
|
+
amount: 10,
|
|
31
|
+
});
|
|
32
|
+
const t2 = await db.insertTransaction({
|
|
33
|
+
account: 'one',
|
|
34
|
+
date: '2025-01-01',
|
|
35
|
+
amount: 12,
|
|
36
|
+
});
|
|
37
|
+
await expect(() =>
|
|
38
|
+
mergeTransactions([{ id: t1 }, { id: t2 }]),
|
|
39
|
+
).rejects.toThrow('Transaction amounts must match for merge');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("fails when transaction id doesn't exist", async () => {
|
|
43
|
+
await prepareDatabase();
|
|
44
|
+
const t1 = await db.insertTransaction({
|
|
45
|
+
account: 'one',
|
|
46
|
+
date: '2025-01-01',
|
|
47
|
+
amount: 10,
|
|
48
|
+
});
|
|
49
|
+
await expect(() =>
|
|
50
|
+
mergeTransactions([{ id: t1 }, { id: 'missing' }]),
|
|
51
|
+
).rejects.toThrow('One of the provided transactions does not exist');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
async function prepareDatabase() {
|
|
56
|
+
await db.insertCategoryGroup({ id: 'group1', name: 'group1', is_income: 0 });
|
|
57
|
+
await db.insertCategory({
|
|
58
|
+
id: '1',
|
|
59
|
+
name: 'cat1',
|
|
60
|
+
cat_group: 'group1',
|
|
61
|
+
is_income: 0,
|
|
62
|
+
});
|
|
63
|
+
await db.insertCategory({
|
|
64
|
+
id: '2',
|
|
65
|
+
name: 'cat2',
|
|
66
|
+
cat_group: 'group1',
|
|
67
|
+
is_income: 0,
|
|
68
|
+
});
|
|
69
|
+
await db.insertAccount({ id: 'one', name: 'one' });
|
|
70
|
+
await db.insertAccount({ id: 'two', name: 'two' });
|
|
71
|
+
await db.insertAccount({ id: 'three', name: 'three', offbudget: 1 });
|
|
72
|
+
await db.insertPayee({ id: 'payee1', name: 'one' });
|
|
73
|
+
await db.insertPayee({ id: 'payee2', name: 'two' });
|
|
74
|
+
await db.insertPayee({ id: 'payee3', name: 'three' });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function getAllTransactions() {
|
|
78
|
+
return db.all<db.DbViewTransaction & { payee_name: db.DbPayee['name'] }>(
|
|
79
|
+
`SELECT t.*, p.name as payee_name
|
|
80
|
+
FROM v_transactions t
|
|
81
|
+
LEFT JOIN payees p ON p.id = t.payee
|
|
82
|
+
ORDER BY date DESC, amount DESC, id
|
|
83
|
+
`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe('Merging success', () => {
|
|
88
|
+
beforeEach(global.emptyDatabase());
|
|
89
|
+
beforeEach(prepareDatabase);
|
|
90
|
+
afterEach(global.emptyDatabase());
|
|
91
|
+
const transaction1 = {
|
|
92
|
+
account: 'one',
|
|
93
|
+
date: '2025-01-01',
|
|
94
|
+
payee: 'payee1',
|
|
95
|
+
notes: 'notes1',
|
|
96
|
+
category: '1',
|
|
97
|
+
amount: 5,
|
|
98
|
+
cleared: false,
|
|
99
|
+
reconciled: false,
|
|
100
|
+
} as TransactionEntity;
|
|
101
|
+
|
|
102
|
+
const dbTransaction1 = {
|
|
103
|
+
account: 'one',
|
|
104
|
+
date: 20250101,
|
|
105
|
+
payee: 'payee1',
|
|
106
|
+
notes: 'notes1',
|
|
107
|
+
category: '1',
|
|
108
|
+
amount: 5,
|
|
109
|
+
cleared: 1,
|
|
110
|
+
reconciled: 1,
|
|
111
|
+
} as db.DbViewTransaction;
|
|
112
|
+
|
|
113
|
+
const transaction2 = {
|
|
114
|
+
account: 'two',
|
|
115
|
+
date: '2025-02-02',
|
|
116
|
+
payee: 'payee2',
|
|
117
|
+
notes: 'notes2',
|
|
118
|
+
category: '2',
|
|
119
|
+
amount: 5,
|
|
120
|
+
cleared: true,
|
|
121
|
+
reconciled: true,
|
|
122
|
+
} as TransactionEntity;
|
|
123
|
+
|
|
124
|
+
const dbTransaction2 = {
|
|
125
|
+
account: 'two',
|
|
126
|
+
date: 20250202,
|
|
127
|
+
payee: 'payee2',
|
|
128
|
+
notes: 'notes2',
|
|
129
|
+
category: '2',
|
|
130
|
+
amount: 5,
|
|
131
|
+
cleared: 1,
|
|
132
|
+
reconciled: 1,
|
|
133
|
+
} as db.DbViewTransaction;
|
|
134
|
+
|
|
135
|
+
it('two banksynced transactions keeps older transaction', async () => {
|
|
136
|
+
const t1 = await db.insertTransaction({
|
|
137
|
+
...transaction1,
|
|
138
|
+
imported_id: 'imported_1',
|
|
139
|
+
});
|
|
140
|
+
const t2 = await db.insertTransaction({
|
|
141
|
+
...transaction2,
|
|
142
|
+
imported_id: 'imported_2',
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(await mergeTransactions([{ id: t1 }, { id: t2 }])).toBe(t1);
|
|
146
|
+
|
|
147
|
+
const transactions = await getAllTransactions();
|
|
148
|
+
expect(transactions.length).toBe(1);
|
|
149
|
+
expect(transactions[0]).toMatchObject({
|
|
150
|
+
...dbTransaction1,
|
|
151
|
+
imported_id: 'imported_1',
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('first banksynced, second manual keeps banksynced values', async () => {
|
|
156
|
+
const t1 = await db.insertTransaction({
|
|
157
|
+
...transaction1,
|
|
158
|
+
imported_id: 'imported_1',
|
|
159
|
+
});
|
|
160
|
+
const t2 = await db.insertTransaction(transaction2);
|
|
161
|
+
|
|
162
|
+
expect(await mergeTransactions([{ id: t1 }, { id: t2 }])).toBe(t1);
|
|
163
|
+
|
|
164
|
+
const transactions = await getAllTransactions();
|
|
165
|
+
expect(transactions.length).toBe(1);
|
|
166
|
+
expect(transactions[0]).toMatchObject({
|
|
167
|
+
...dbTransaction1,
|
|
168
|
+
imported_id: 'imported_1',
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('first file imported, second banksycned keeps banksynced values', async () => {
|
|
173
|
+
const t1 = await db.insertTransaction({
|
|
174
|
+
...transaction1,
|
|
175
|
+
imported_payee: 'payee',
|
|
176
|
+
});
|
|
177
|
+
const t2 = await db.insertTransaction({
|
|
178
|
+
...transaction2,
|
|
179
|
+
imported_id: 'imported_2',
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
expect(await mergeTransactions([{ id: t1 }, { id: t2 }])).toBe(t2);
|
|
183
|
+
|
|
184
|
+
const transactions = await getAllTransactions();
|
|
185
|
+
expect(transactions.length).toBe(1);
|
|
186
|
+
expect(transactions[0]).toMatchObject({
|
|
187
|
+
...dbTransaction2,
|
|
188
|
+
imported_id: 'imported_2',
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('second file imported, first banksycned keeps banksynced values', async () => {
|
|
193
|
+
const t1 = await db.insertTransaction({
|
|
194
|
+
...transaction1,
|
|
195
|
+
imported_id: 'imported_1',
|
|
196
|
+
});
|
|
197
|
+
const t2 = await db.insertTransaction({
|
|
198
|
+
...transaction2,
|
|
199
|
+
imported_payee: 'payee',
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(await mergeTransactions([{ id: t1 }, { id: t2 }])).toBe(t1);
|
|
203
|
+
|
|
204
|
+
const transactions = await getAllTransactions();
|
|
205
|
+
expect(transactions.length).toBe(1);
|
|
206
|
+
expect(transactions[0]).toMatchObject({
|
|
207
|
+
...dbTransaction1,
|
|
208
|
+
imported_id: 'imported_1',
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('second file imported, first manual keeps file imported values', async () => {
|
|
213
|
+
const t1 = await db.insertTransaction(transaction1);
|
|
214
|
+
const t2 = await db.insertTransaction({
|
|
215
|
+
...transaction2,
|
|
216
|
+
imported_payee: 'payee',
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
expect(await mergeTransactions([{ id: t1 }, { id: t2 }])).toBe(t2);
|
|
220
|
+
|
|
221
|
+
const transactions = await getAllTransactions();
|
|
222
|
+
expect(transactions.length).toBe(1);
|
|
223
|
+
expect(transactions[0]).toMatchObject({
|
|
224
|
+
...dbTransaction2,
|
|
225
|
+
imported_payee: 'payee',
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('first file imported, second manual keeps file imported values', async () => {
|
|
230
|
+
const t1 = await db.insertTransaction({
|
|
231
|
+
...transaction1,
|
|
232
|
+
imported_payee: 'payee',
|
|
233
|
+
});
|
|
234
|
+
const t2 = await db.insertTransaction(transaction2);
|
|
235
|
+
|
|
236
|
+
expect(await mergeTransactions([{ id: t1 }, { id: t2 }])).toBe(t1);
|
|
237
|
+
|
|
238
|
+
const transactions = await getAllTransactions();
|
|
239
|
+
expect(transactions.length).toBe(1);
|
|
240
|
+
expect(transactions[0]).toMatchObject({
|
|
241
|
+
...dbTransaction1,
|
|
242
|
+
imported_payee: 'payee',
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('second banksynced, first manual keeps banksynced values', async () => {
|
|
247
|
+
const t1 = await db.insertTransaction(transaction1);
|
|
248
|
+
const t2 = await db.insertTransaction({
|
|
249
|
+
...transaction2,
|
|
250
|
+
imported_id: 'imported_2',
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
expect(await mergeTransactions([{ id: t1 }, { id: t2 }])).toBe(t2);
|
|
254
|
+
|
|
255
|
+
const transactions = await getAllTransactions();
|
|
256
|
+
expect(transactions.length).toBe(1);
|
|
257
|
+
expect(transactions[0]).toMatchObject({
|
|
258
|
+
...dbTransaction2,
|
|
259
|
+
imported_id: 'imported_2',
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('missing values in keep are filled in with drop values', async () => {
|
|
264
|
+
// only insert required fields, imported to be kept
|
|
265
|
+
const t1 = await db.insertTransaction({
|
|
266
|
+
account: 'one',
|
|
267
|
+
amount: 5,
|
|
268
|
+
date: '2025-01-01',
|
|
269
|
+
imported_id: 'imported_1',
|
|
270
|
+
});
|
|
271
|
+
const t2 = await db.insertTransaction(transaction2);
|
|
272
|
+
|
|
273
|
+
expect(await mergeTransactions([{ id: t1 }, { id: t2 }])).toBe(t1);
|
|
274
|
+
const transactions = await getAllTransactions();
|
|
275
|
+
expect(transactions.length).toBe(1);
|
|
276
|
+
expect(transactions[0]).toMatchObject({
|
|
277
|
+
...dbTransaction2,
|
|
278
|
+
// values that should be kept from t1
|
|
279
|
+
id: t1,
|
|
280
|
+
account: 'one',
|
|
281
|
+
amount: 5,
|
|
282
|
+
date: 20250101,
|
|
283
|
+
imported_id: 'imported_1',
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('preserves split categories when merging split transaction with uncategorized imported transaction', async () => {
|
|
288
|
+
// Create a manual transaction with splits
|
|
289
|
+
const manualParent = await db.insertTransaction({
|
|
290
|
+
account: 'one',
|
|
291
|
+
amount: 100,
|
|
292
|
+
date: '2025-01-01',
|
|
293
|
+
is_parent: true,
|
|
294
|
+
category: null,
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const _sub1 = await db.insertTransaction({
|
|
298
|
+
account: 'one',
|
|
299
|
+
amount: 60,
|
|
300
|
+
date: '2025-01-01',
|
|
301
|
+
category: '1',
|
|
302
|
+
notes: 'Food',
|
|
303
|
+
is_child: true,
|
|
304
|
+
parent_id: manualParent,
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const _sub2 = await db.insertTransaction({
|
|
308
|
+
account: 'one',
|
|
309
|
+
amount: 40,
|
|
310
|
+
date: '2025-01-01',
|
|
311
|
+
category: '2',
|
|
312
|
+
notes: 'Transport',
|
|
313
|
+
is_child: true,
|
|
314
|
+
parent_id: manualParent,
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// Create an uncategorized imported transaction
|
|
318
|
+
const imported = await db.insertTransaction({
|
|
319
|
+
account: 'one',
|
|
320
|
+
amount: 100,
|
|
321
|
+
date: '2025-01-02',
|
|
322
|
+
imported_id: 'imported_1',
|
|
323
|
+
category: null,
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// Merge: imported transaction should be kept (because it has imported_id)
|
|
327
|
+
const keptId = await mergeTransactions([
|
|
328
|
+
{ id: manualParent },
|
|
329
|
+
{ id: imported },
|
|
330
|
+
]);
|
|
331
|
+
expect(keptId).toBe(imported);
|
|
332
|
+
|
|
333
|
+
// Check that the kept transaction is now a parent
|
|
334
|
+
const keptTransaction = await db.getTransaction(imported);
|
|
335
|
+
expect(keptTransaction?.is_parent).toBe(true);
|
|
336
|
+
expect(keptTransaction?.category).toBeNull();
|
|
337
|
+
expect(keptTransaction?.imported_id).toBe('imported_1');
|
|
338
|
+
|
|
339
|
+
// Check that subtransactions were transferred and still exist
|
|
340
|
+
const allTransactions = await getAllTransactions();
|
|
341
|
+
const childTransactions = allTransactions.filter(
|
|
342
|
+
t => t.parent_id === imported,
|
|
343
|
+
);
|
|
344
|
+
expect(childTransactions.length).toBe(2);
|
|
345
|
+
|
|
346
|
+
// Verify the subtransactions have the correct parent_id and preserved their categories
|
|
347
|
+
const child1 = childTransactions.find(t => t.amount === 60);
|
|
348
|
+
const child2 = childTransactions.find(t => t.amount === 40);
|
|
349
|
+
|
|
350
|
+
expect(child1).toMatchObject({
|
|
351
|
+
parent_id: imported,
|
|
352
|
+
category: '1',
|
|
353
|
+
notes: 'Food',
|
|
354
|
+
is_child: 1, // From getAllTransactions (db.all), not converted
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
expect(child2).toMatchObject({
|
|
358
|
+
parent_id: imported,
|
|
359
|
+
category: '2',
|
|
360
|
+
notes: 'Transport',
|
|
361
|
+
is_child: 1, // From getAllTransactions (db.all), not converted
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// Verify no orphaned subtransactions remain
|
|
365
|
+
const orphanedChildren = allTransactions.filter(
|
|
366
|
+
t => t.parent_id === manualParent,
|
|
367
|
+
);
|
|
368
|
+
expect(orphanedChildren.length).toBe(0);
|
|
369
|
+
});
|
|
370
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { q } from '../../shared/query';
|
|
2
|
+
import {
|
|
3
|
+
deleteTransaction as sharedDeleteTransaction,
|
|
4
|
+
ungroupTransactions,
|
|
5
|
+
} from '../../shared/transactions';
|
|
6
|
+
import type { TransactionEntity } from '../../types/models';
|
|
7
|
+
import { aqlQuery } from '../aql';
|
|
8
|
+
import * as db from '../db';
|
|
9
|
+
|
|
10
|
+
import { batchUpdateTransactions } from '.';
|
|
11
|
+
|
|
12
|
+
export async function mergeTransactions(
|
|
13
|
+
transactions: Pick<TransactionEntity, 'id'>[],
|
|
14
|
+
): Promise<TransactionEntity['id']> {
|
|
15
|
+
// make sure all values have ids
|
|
16
|
+
const txIds = transactions?.map(x => x?.id).filter(Boolean) || [];
|
|
17
|
+
if (txIds.length !== 2) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
'Merging is only possible with 2 transactions, but found ' +
|
|
20
|
+
JSON.stringify(transactions),
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// get most recent transactions
|
|
25
|
+
const [a, b]: TransactionEntity[] = await Promise.all(
|
|
26
|
+
txIds.map(db.getTransaction),
|
|
27
|
+
);
|
|
28
|
+
if (!a || !b) {
|
|
29
|
+
throw new Error('One of the provided transactions does not exist');
|
|
30
|
+
} else if (a.amount !== b.amount) {
|
|
31
|
+
throw new Error('Transaction amounts must match for merge');
|
|
32
|
+
}
|
|
33
|
+
const { keep, drop } = determineKeepDrop(a, b);
|
|
34
|
+
|
|
35
|
+
// Load subtransactions with a single query, then split by parent_id in memory
|
|
36
|
+
const keepSubtransactions: TransactionEntity[] = [];
|
|
37
|
+
const dropSubtransactions: TransactionEntity[] = [];
|
|
38
|
+
const parents: string[] = [];
|
|
39
|
+
if (keep.is_parent) parents.push(keep.id);
|
|
40
|
+
if (drop.is_parent) parents.push(drop.id);
|
|
41
|
+
|
|
42
|
+
let rows: TransactionEntity[] = [];
|
|
43
|
+
if (parents.length === 2) {
|
|
44
|
+
rows = await db.all<TransactionEntity>(
|
|
45
|
+
'SELECT * FROM v_transactions WHERE parent_id IN (?, ?)',
|
|
46
|
+
parents,
|
|
47
|
+
);
|
|
48
|
+
} else if (parents.length === 1) {
|
|
49
|
+
rows = await db.all<TransactionEntity>(
|
|
50
|
+
'SELECT * FROM v_transactions WHERE parent_id = ?',
|
|
51
|
+
parents,
|
|
52
|
+
);
|
|
53
|
+
} // else: both are non-parents → rows stays []
|
|
54
|
+
|
|
55
|
+
for (const row of rows) {
|
|
56
|
+
if (row.parent_id === keep.id) keepSubtransactions.push(row);
|
|
57
|
+
else if (row.parent_id === drop.id) dropSubtransactions.push(row);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Determine which transaction has subtransactions (split categories)
|
|
61
|
+
const keepHasSubtransactions = keepSubtransactions.length > 0;
|
|
62
|
+
const dropHasSubtransactions = dropSubtransactions.length > 0;
|
|
63
|
+
|
|
64
|
+
// If keep doesn't have subtransactions but drop does, transfer them
|
|
65
|
+
if (!keepHasSubtransactions && dropHasSubtransactions) {
|
|
66
|
+
// Update each subtransaction to point to the kept parent
|
|
67
|
+
await Promise.all(
|
|
68
|
+
dropSubtransactions.map(sub =>
|
|
69
|
+
db.updateTransaction({
|
|
70
|
+
id: sub.id,
|
|
71
|
+
parent_id: keep.id,
|
|
72
|
+
} as TransactionEntity),
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
// Mark keep as a parent transaction
|
|
76
|
+
await db.updateTransaction({
|
|
77
|
+
id: keep.id,
|
|
78
|
+
is_parent: true,
|
|
79
|
+
category: null, // Parent transactions with splits shouldn't have a category
|
|
80
|
+
payee: keep.payee || drop.payee,
|
|
81
|
+
notes: keep.notes || drop.notes,
|
|
82
|
+
cleared: keep.cleared || drop.cleared,
|
|
83
|
+
reconciled: keep.reconciled || drop.reconciled,
|
|
84
|
+
} as unknown as TransactionEntity);
|
|
85
|
+
} else {
|
|
86
|
+
// Normal merge without subtransactions
|
|
87
|
+
await db.updateTransaction({
|
|
88
|
+
id: keep.id,
|
|
89
|
+
payee: keep.payee || drop.payee,
|
|
90
|
+
category: keep.category || drop.category,
|
|
91
|
+
notes: keep.notes || drop.notes,
|
|
92
|
+
cleared: keep.cleared || drop.cleared,
|
|
93
|
+
reconciled: keep.reconciled || drop.reconciled,
|
|
94
|
+
} as TransactionEntity);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Delete the dropped transaction using shared deleteTransaction to
|
|
98
|
+
// intelligently handle possible parent/child cascading logic
|
|
99
|
+
const { data: transactionsToDelete } = await aqlQuery(
|
|
100
|
+
q('transactions')
|
|
101
|
+
.filter({ id: drop.id })
|
|
102
|
+
.select('*')
|
|
103
|
+
.options({ splits: 'grouped' }),
|
|
104
|
+
);
|
|
105
|
+
const ungroupedTransactions = ungroupTransactions(transactionsToDelete);
|
|
106
|
+
if (ungroupedTransactions.length > 0) {
|
|
107
|
+
const { diff } = sharedDeleteTransaction(ungroupedTransactions, drop.id);
|
|
108
|
+
await batchUpdateTransactions(diff);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return keep.id;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function determineKeepDrop(
|
|
115
|
+
a: TransactionEntity,
|
|
116
|
+
b: TransactionEntity,
|
|
117
|
+
): { keep: TransactionEntity; drop: TransactionEntity } {
|
|
118
|
+
// if one is imported through bank sync and the other is manual,
|
|
119
|
+
// keep the imported transaction
|
|
120
|
+
if (b.imported_id && !a.imported_id) {
|
|
121
|
+
return { keep: b, drop: a };
|
|
122
|
+
} else if (a.imported_id && !b.imported_id) {
|
|
123
|
+
return { keep: a, drop: b };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// same logic but for imported transactions
|
|
127
|
+
if (b.imported_payee && !a.imported_payee) {
|
|
128
|
+
return { keep: b, drop: a };
|
|
129
|
+
} else if (a.imported_payee && !b.imported_payee) {
|
|
130
|
+
return { keep: a, drop: b };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// keep the earlier transaction
|
|
134
|
+
if (a.date.localeCompare(b.date) < 0) {
|
|
135
|
+
return { keep: a, drop: b };
|
|
136
|
+
} else {
|
|
137
|
+
return { keep: b, drop: a };
|
|
138
|
+
}
|
|
139
|
+
}
|