From fe85652e4d3e8906824baddaafd05d19105579b8 Mon Sep 17 00:00:00 2001 From: Joe Carstairs Date: Mon, 25 Aug 2025 16:35:45 +0000 Subject: task-072 Co-authored-by: Joe Carstairs Reviewed-on: https://git.joeac.net/joeac/schist/pulls/3 Co-authored-by: Joe Carstairs Co-committed-by: Joe Carstairs --- .gitignore | 2 +- Cargo.lock | 685 +++++++++++++++++++-- Cargo.toml | 24 +- requirements/kanban.md | 4 +- requirements/tasks/epic-000.md | 3 + requirements/tasks/task-072.md | 2 + requirements/tasks/task-087.md | 8 + requirements/tasks/task-088.md | 8 + requirements/tasks/task-089.md | 8 + schist_core/schist_fakes/src/drip.rs | 8 +- schist_core/schist_fakes/src/pipe.rs | 3 +- .../2024-08-31-084439_initial_setup/up.sql | 3 +- .../2025-08-15-090859_dummy_data/down.sql | 39 -- .../migrations/2025-08-15-090859_dummy_data/up.sql | 14 - schist_core/schist_models/src/bucket.rs | 13 +- schist_core/schist_models/src/pipe.rs | 3 +- schist_core/schist_models/src/schema.rs | 3 +- schist_core/schist_queries/src/clear.rs | 13 +- schist_core/schist_queries/src/utils/mod.rs | 4 +- .../schist_queries/tests/category_transfers.rs | 5 +- schist_desktop_gui/Cargo.toml | 2 + schist_desktop_gui/src/connection.rs | 20 - schist_desktop_gui/src/database_connection.rs | 71 +++ schist_desktop_gui/src/dialog.rs | 7 + .../src/dialog/import_from_actualbudget_dialog.rs | 26 + schist_desktop_gui/src/dialog/new_file_dialog.rs | 15 + schist_desktop_gui/src/dialog/pick_file_dialog.rs | 14 + schist_desktop_gui/src/gui.rs | 50 +- schist_desktop_gui/src/gui/components.rs | 2 +- .../components/balances_view/view_balances_view.rs | 3 +- .../components/balances_view/view_group_names.rs | 40 +- .../src/gui/components/bucket_name_and_balance.rs | 11 +- .../src/gui/components/buckets_view.rs | 4 +- schist_desktop_gui/src/gui/components/button.rs | 21 + schist_desktop_gui/src/gui/components/text.rs | 94 ++- .../src/gui/components/transactions_view.rs | 4 +- schist_desktop_gui/src/gui/screens.rs | 5 +- schist_desktop_gui/src/gui/screens/files_screen.rs | 36 ++ .../screens/files_screen/update_files_screen.rs | 125 ++++ .../gui/screens/files_screen/view_files_screen.rs | 106 ++++ .../src/gui/screens/main_screen/main_screen.rs | 10 +- .../src/gui/screens/main_screen/view.rs | 4 +- schist_desktop_gui/src/main.rs | 33 +- schist_desktop_gui/src/paths.rs | 56 ++ schist_desktop_gui/src/style.rs | 4 + schist_desktop_gui/src/theme.rs | 68 +- .../src/actualbudget_category.rs | 1 + .../src/actualbudget_category_group.rs | 13 + .../src/actualbudget_transaction.rs | 3 +- .../actualbudget_fakes/src/lib.rs | 1 + .../src/actualbudget_category.rs | 1 + .../src/actualbudget_category_group.rs | 9 + .../actualbudget_models/src/actualbudget_schema.rs | 12 +- .../src/actualbudget_transaction.rs | 11 +- .../actualbudget_models/src/lib.rs | 3 + .../src/actualbudget_category_groups.rs | 11 + .../actualbudget_queries/src/lib.rs | 1 + .../tests/resources/actualbudget_test_db.sqlite | Bin 528384 -> 573440 bytes .../resources/actualbudget_test_db_metadata.json | 10 +- .../actualbudget_to_schist_transformer/Cargo.toml | 1 + .../src/actualbudget_state.rs | 24 +- .../src/transform_state.rs | 27 + .../src/transform_state/is_bucket_transfer.rs | 46 ++ .../src/transform_state/is_category_transfer.rs | 18 - .../src/transform_state/mod.rs | 27 - .../transform_state/transform_account_transfers.rs | 18 +- .../src/transform_state/transform_accounts.rs | 182 +++++- .../src/transform_state/transform_budget_drips.rs | 325 ---------- .../src/transform_state/transform_categories.rs | 143 ----- .../transform_categories_to_buckets.rs | 263 ++++++++ .../transform_category_transfers.rs | 408 ------------ .../src/transform_state/transform_state.rs | 49 +- .../src/transform_state/transform_transactions.rs | 76 +-- .../transform_transactions_to_bucket_transfers.rs | 383 ++++++++++++ .../src/transform_state/transform_zero_budgets.rs | 6 - .../transform_zero_budgets_to_drips.rs | 325 ++++++++++ .../transform_zero_budgets_to_pipes.rs | 211 +++++++ .../tests/common/account_matchers.rs | 8 +- .../tests/common/bucket_matchers.rs | 25 +- .../tests/common/bucket_names.rs | 22 +- .../tests/common/budget_drip_matchers.rs | 211 ------- .../tests/common/drip_matchers.rs | 202 ++++++ .../tests/common/mod.rs | 10 +- .../tests/common/pipe_matchers.rs | 36 ++ .../tests/common/transaction_matchers.rs | 112 ++-- .../tests/resources/actualbudget_test_db.sqlite | Bin 73 -> 573440 bytes .../tests/run.rs | 26 +- 87 files changed, 3323 insertions(+), 1620 deletions(-) create mode 100644 requirements/tasks/task-087.md create mode 100644 requirements/tasks/task-088.md create mode 100644 requirements/tasks/task-089.md delete mode 100644 schist_core/schist_models/migrations/2025-08-15-090859_dummy_data/down.sql delete mode 100644 schist_core/schist_models/migrations/2025-08-15-090859_dummy_data/up.sql delete mode 100644 schist_desktop_gui/src/connection.rs create mode 100644 schist_desktop_gui/src/database_connection.rs create mode 100644 schist_desktop_gui/src/dialog.rs create mode 100644 schist_desktop_gui/src/dialog/import_from_actualbudget_dialog.rs create mode 100644 schist_desktop_gui/src/dialog/new_file_dialog.rs create mode 100644 schist_desktop_gui/src/dialog/pick_file_dialog.rs create mode 100644 schist_desktop_gui/src/gui/screens/files_screen.rs create mode 100644 schist_desktop_gui/src/gui/screens/files_screen/update_files_screen.rs create mode 100644 schist_desktop_gui/src/gui/screens/files_screen/view_files_screen.rs create mode 100644 schist_desktop_gui/src/paths.rs create mode 100644 transformers/actualbudget_transformer/actualbudget_fakes/src/actualbudget_category_group.rs create mode 100644 transformers/actualbudget_transformer/actualbudget_models/src/actualbudget_category_group.rs create mode 100644 transformers/actualbudget_transformer/actualbudget_queries/src/actualbudget_category_groups.rs create mode 100644 transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state.rs create mode 100644 transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/is_bucket_transfer.rs delete mode 100644 transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/is_category_transfer.rs delete mode 100644 transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/mod.rs delete mode 100644 transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_budget_drips.rs delete mode 100644 transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_categories.rs create mode 100644 transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_categories_to_buckets.rs delete mode 100644 transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_category_transfers.rs create mode 100644 transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_transactions_to_bucket_transfers.rs delete mode 100644 transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_zero_budgets.rs create mode 100644 transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_zero_budgets_to_drips.rs create mode 100644 transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_zero_budgets_to_pipes.rs delete mode 100644 transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/budget_drip_matchers.rs create mode 100644 transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/drip_matchers.rs create mode 100644 transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/pipe_matchers.rs diff --git a/.gitignore b/.gitignore index b6f52a1..42b6207 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,4 @@ target *.keep .zed .vscode -**/*.sqlite +user_data.sqlite diff --git a/Cargo.lock b/Cargo.lock index 0334664..65ced99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,6 +64,7 @@ dependencies = [ "diesel_migrations", "itertools", "libsqlite3-sys", + "schist_fakes", "schist_models", "schist_queries", "schist_traits", @@ -201,6 +202,28 @@ dependencies = [ "libloading 0.7.4", ] +[[package]] +name = "ashpd" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cbdf310d77fd3aaee6ea2093db7011dc2d35d2eb3481e5607f1f8d942ed99df" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.2", + "raw-window-handle", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus 5.9.0", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -279,6 +302,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + [[package]] name = "async-process" version = "2.4.0" @@ -418,7 +452,16 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" dependencies = [ - "objc2", + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "340d2f0bdb2a43c1d3cd40513185b2bd7def0aa1052f956455114bc98f82dcf2" +dependencies = [ + "objc2 0.6.2", ] [[package]] @@ -563,9 +606,9 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b7f4aaa047ba3c3630b080bb9860894732ff23e2aee290a418909aa6d5df38f" dependencies = [ - "objc2", - "objc2-app-kit", - "objc2-foundation", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", ] [[package]] @@ -839,7 +882,7 @@ dependencies = [ "rust-ini", "web-sys", "winreg", - "zbus", + "zbus 4.4.0", ] [[package]] @@ -1030,6 +1073,29 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.9.1", + "block2 0.6.1", + "libc", + "objc2 0.6.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "dlib" version = "0.5.2" @@ -1301,6 +1367,15 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures" version = "0.3.31" @@ -1829,12 +1904,119 @@ dependencies = [ "winit", ] +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "ident_case" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.10.0" @@ -2010,6 +2192,12 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + [[package]] name = "lock_api" version = "0.4.13" @@ -2183,6 +2371,19 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.9.1", + "cfg-if", + "cfg_aliases 0.2.1", + "libc", + "memoffset", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -2256,6 +2457,15 @@ dependencies = [ "objc2-encode", ] +[[package]] +name = "objc2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "561f357ba7f3a2a61563a186a163d0a3a5247e1089524a3981d49adb775078bc" +dependencies = [ + "objc2-encode", +] + [[package]] name = "objc2-app-kit" version = "0.2.2" @@ -2263,15 +2473,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ "bitflags 2.9.1", - "block2", + "block2 0.5.1", "libc", - "objc2", + "objc2 0.5.2", "objc2-core-data", "objc2-core-image", - "objc2-foundation", + "objc2-foundation 0.2.2", "objc2-quartz-core", ] +[[package]] +name = "objc2-app-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" +dependencies = [ + "bitflags 2.9.1", + "block2 0.6.1", + "objc2 0.6.2", + "objc2-foundation 0.3.1", +] + [[package]] name = "objc2-cloud-kit" version = "0.2.2" @@ -2279,10 +2501,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ "bitflags 2.9.1", - "block2", - "objc2", + "block2 0.5.1", + "objc2 0.5.2", "objc2-core-location", - "objc2-foundation", + "objc2-foundation 0.2.2", ] [[package]] @@ -2291,9 +2513,9 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" dependencies = [ - "block2", - "objc2", - "objc2-foundation", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] @@ -2303,9 +2525,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ "bitflags 2.9.1", - "block2", - "objc2", - "objc2-foundation", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" +dependencies = [ + "bitflags 2.9.1", + "dispatch2", + "objc2 0.6.2", ] [[package]] @@ -2314,9 +2547,9 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ - "block2", - "objc2", - "objc2-foundation", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", "objc2-metal", ] @@ -2326,10 +2559,10 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" dependencies = [ - "block2", - "objc2", + "block2 0.5.1", + "objc2 0.5.2", "objc2-contacts", - "objc2-foundation", + "objc2-foundation 0.2.2", ] [[package]] @@ -2345,10 +2578,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ "bitflags 2.9.1", - "block2", + "block2 0.5.1", "dispatch", "libc", - "objc2", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" +dependencies = [ + "bitflags 2.9.1", + "objc2 0.6.2", + "objc2-core-foundation", ] [[package]] @@ -2357,10 +2601,10 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" dependencies = [ - "block2", - "objc2", - "objc2-app-kit", - "objc2-foundation", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", ] [[package]] @@ -2370,9 +2614,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ "bitflags 2.9.1", - "block2", - "objc2", - "objc2-foundation", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] @@ -2382,9 +2626,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ "bitflags 2.9.1", - "block2", - "objc2", - "objc2-foundation", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", "objc2-metal", ] @@ -2394,8 +2638,8 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" dependencies = [ - "objc2", - "objc2-foundation", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] @@ -2405,13 +2649,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ "bitflags 2.9.1", - "block2", - "objc2", + "block2 0.5.1", + "objc2 0.5.2", "objc2-cloud-kit", "objc2-core-data", "objc2-core-image", "objc2-core-location", - "objc2-foundation", + "objc2-foundation 0.2.2", "objc2-link-presentation", "objc2-quartz-core", "objc2-symbols", @@ -2425,9 +2669,9 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" dependencies = [ - "block2", - "objc2", - "objc2-foundation", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] @@ -2437,10 +2681,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ "bitflags 2.9.1", - "block2", - "objc2", + "block2 0.5.1", + "objc2 0.5.2", "objc2-core-location", - "objc2-foundation", + "objc2-foundation 0.2.2", ] [[package]] @@ -2597,9 +2841,9 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "phf" @@ -2618,7 +2862,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand", + "rand 0.8.5", ] [[package]] @@ -2719,6 +2963,21 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "potential_utf" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -2795,8 +3054,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", ] [[package]] @@ -2806,7 +3075,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", ] [[package]] @@ -2818,6 +3097,15 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.3", +] + [[package]] name = "range-alloc" version = "0.1.4" @@ -2921,6 +3209,30 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd", + "block2 0.6.1", + "dispatch2", + "js-sys", + "log", + "objc2 0.6.2", + "objc2-app-kit 0.3.1", + "objc2-core-foundation", + "objc2-foundation 0.3.1", + "pollster", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + [[package]] name = "roxmltree" version = "0.20.0" @@ -3017,6 +3329,7 @@ dependencies = [ name = "schist_desktop_gui" version = "0.1.0" dependencies = [ + "actualbudget_to_schist_transformer", "anyhow", "diesel", "diesel_migrations", @@ -3024,6 +3337,7 @@ dependencies = [ "iced", "itertools", "libsqlite3-sys", + "rfd", "schist_fakes", "schist_models", "schist_queries", @@ -3280,8 +3594,8 @@ dependencies = [ "js-sys", "log", "memmap2", - "objc2", - "objc2-foundation", + "objc2 0.5.2", + "objc2-foundation 0.2.2", "objc2-quartz-core", "raw-window-handle", "redox_syscall 0.5.17", @@ -3305,6 +3619,12 @@ dependencies = [ "bitflags 2.9.1", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + [[package]] name = "static_assertions" version = "1.1.0" @@ -3362,6 +3682,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "sys-locale" version = "0.3.2" @@ -3503,6 +3834,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinyvec" version = "1.9.0" @@ -3700,6 +4041,30 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "url" +version = "2.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "137a3c834eaf7139b73688502f3f1141a0337c5d8e4d9b536f9b8c796e26a7c4" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "vcpkg" version = "0.2.15" @@ -4477,7 +4842,7 @@ dependencies = [ "android-activity", "atomic-waker", "bitflags 2.9.1", - "block2", + "block2 0.5.1", "bytemuck", "calloop", "cfg_aliases 0.2.1", @@ -4490,9 +4855,9 @@ dependencies = [ "libc", "memmap2", "ndk", - "objc2", - "objc2-app-kit", - "objc2-foundation", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", "objc2-ui-kit", "orbclient", "percent-encoding", @@ -4546,6 +4911,12 @@ dependencies = [ "bitflags 2.9.1", ] +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + [[package]] name = "x11-dl" version = "2.21.0" @@ -4625,6 +4996,30 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c94451ac9513335b5e23d7a8a2b61a7102398b8cca5160829d313e84c9d98be1" +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", + "synstructure", +] + [[package]] name = "zbus" version = "4.4.0" @@ -4647,9 +5042,9 @@ dependencies = [ "futures-sink", "futures-util", "hex", - "nix", + "nix 0.29.0", "ordered-stream", - "rand", + "rand 0.8.5", "serde", "serde_repr", "sha1", @@ -4658,9 +5053,42 @@ dependencies = [ "uds_windows", "windows-sys 0.52.0", "xdg-home", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus" +version = "5.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb4f9a464286d42851d18a605f7193b8febaf5b0919d71c6399b7b26e5b0aad" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "nix 0.30.1", + "ordered-stream", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "windows-sys 0.59.0", + "winnow", + "zbus_macros 5.9.0", + "zbus_names 4.2.0", + "zvariant 5.6.0", ] [[package]] @@ -4673,7 +5101,22 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.104", - "zvariant_utils", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zbus_macros" +version = "5.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef9859f68ee0c4ee2e8cde84737c78e3f4c54f946f2a38645d0d4c7a95327659" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.104", + "zbus_names 4.2.0", + "zvariant 5.6.0", + "zvariant_utils 3.2.0", ] [[package]] @@ -4684,7 +5127,19 @@ checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" dependencies = [ "serde", "static_assertions", - "zvariant", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus_names" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" +dependencies = [ + "serde", + "static_assertions", + "winnow", + "zvariant 5.6.0", ] [[package]] @@ -4713,6 +5168,60 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "zvariant" version = "4.2.0" @@ -4723,7 +5232,22 @@ dependencies = [ "enumflags2", "serde", "static_assertions", - "zvariant_derive", + "zvariant_derive 4.2.0", +] + +[[package]] +name = "zvariant" +version = "5.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91b3680bb339216abd84714172b5138a4edac677e641ef17e1d8cb1b3ca6e6f" +dependencies = [ + "endi", + "enumflags2", + "serde", + "url", + "winnow", + "zvariant_derive 5.6.0", + "zvariant_utils 3.2.0", ] [[package]] @@ -4736,7 +5260,20 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.104", - "zvariant_utils", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zvariant_derive" +version = "5.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8c68501be459a8dbfffbe5d792acdd23b4959940fc87785fb013b32edbc208" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.104", + "zvariant_utils 3.2.0", ] [[package]] @@ -4749,3 +5286,17 @@ dependencies = [ "quote", "syn 2.0.104", ] + +[[package]] +name = "zvariant_utils" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e16edfee43e5d7b553b77872d99bc36afdda75c223ca7ad5e3fbecd82ca5fc34" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "static_assertions", + "syn 2.0.104", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml index f9bb9fc..a763f80 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,20 +1,21 @@ [workspace] members = [ - "schist_core/schist_fakes", - "schist_core/schist_models", - "schist_core/schist_queries", - "schist_core/schist_schema", - "schist_core/schist_traits", - "schist_desktop_gui", - "transformers/actualbudget_transformer/actualbudget_fakes", - "transformers/actualbudget_transformer/actualbudget_models", - "transformers/actualbudget_transformer/actualbudget_queries", - "transformers/actualbudget_transformer/actualbudget_schema", - "transformers/actualbudget_transformer/actualbudget_to_schist_transformer", + "schist_core/schist_fakes", + "schist_core/schist_models", + "schist_core/schist_queries", + "schist_core/schist_schema", + "schist_core/schist_traits", + "schist_desktop_gui", + "transformers/actualbudget_transformer/actualbudget_fakes", + "transformers/actualbudget_transformer/actualbudget_models", + "transformers/actualbudget_transformer/actualbudget_queries", + "transformers/actualbudget_transformer/actualbudget_schema", + "transformers/actualbudget_transformer/actualbudget_to_schist_transformer", ] resolver = "2" [workspace.dependencies] +actualbudget_to_schist_transformer = { path = "transformers/actualbudget_transformer/actualbudget_to_schist_transformer" } anyhow = { version = "1.0.0", features = ["backtrace"] } chrono = { version = "0.4.38" } derive_builder = { version = "0.20.2" } @@ -25,6 +26,7 @@ dotenvy = "0.15.7" iced = "0.13.1" itertools = "0.13.0" libsqlite3-sys = { version = "0.30.1", features = ["bundled"] } +rfd = "0.15.4" schist_fakes = { path = "schist_core/schist_fakes" } schist_models = { path = "schist_core/schist_models" } schist_queries = { path = "schist_core/schist_queries" } diff --git a/requirements/kanban.md b/requirements/kanban.md index 52a6d7a..1ceaaef 100644 --- a/requirements/kanban.md +++ b/requirements/kanban.md @@ -4,7 +4,8 @@ | -------- | -------- | -------- | | | | epic-000 | | ======== | ======== | ======== | -| | task-072 | | +| | task-089 | | +| | task-088 | | | | task-085 | | | | task-001 | | | | task-064 | | @@ -24,6 +25,7 @@ | | task-081 | | | | task-077 | | | | task-079 | | +| | task-087 | | | ======== | ======== | ======== | | epic-008 | | | diff --git a/requirements/tasks/epic-000.md b/requirements/tasks/epic-000.md index 9e8d91a..c8be413 100644 --- a/requirements/tasks/epic-000.md +++ b/requirements/tasks/epic-000.md @@ -11,6 +11,8 @@ Status: in progress - [x] task-073 (UI layout & nav) - [x] task-012 (SQLite) - [ ] task-072 (import from Actualbudget) +- [ ] task-089 (keyboard nav in files screen) +- [ ] task-088 (caching open file) - [ ] task-085 (view transactions) - [ ] task-001 (inserting transactions) - [ ] task-064 (deleting transactions) @@ -32,3 +34,4 @@ Status: in progress - [ ] task-082 (Overview in Balances view) - [ ] task-084 (groups in Balances view) - [ ] task-083 (hidden buckets in Balances view) +- [ ] task-087 (closing files) diff --git a/requirements/tasks/task-072.md b/requirements/tasks/task-072.md index a5626e0..96a7c20 100644 --- a/requirements/tasks/task-072.md +++ b/requirements/tasks/task-072.md @@ -3,4 +3,6 @@ Schist must have a desktop interface for importing transactions from an Actualbudget SQLite file. +Status: done + Epic: epic-000 diff --git a/requirements/tasks/task-087.md b/requirements/tasks/task-087.md new file mode 100644 index 0000000..04ee514 --- /dev/null +++ b/requirements/tasks/task-087.md @@ -0,0 +1,8 @@ +# task-087 + +Schist's desktop GUI should enable the user to close their current file and +return to the files screen. + +Source: task-072 + +Epic: epic-000 diff --git a/requirements/tasks/task-088.md b/requirements/tasks/task-088.md new file mode 100644 index 0000000..0d0ed59 --- /dev/null +++ b/requirements/tasks/task-088.md @@ -0,0 +1,8 @@ +# task-088 + +Schist's desktop GUI should open the same file the user last had open without +passing through the files screen. + +Source: task-072 + +Epic: epic-000 diff --git a/requirements/tasks/task-089.md b/requirements/tasks/task-089.md new file mode 100644 index 0000000..2020eae --- /dev/null +++ b/requirements/tasks/task-089.md @@ -0,0 +1,8 @@ +# task-089 + +Schist's desktop GUI should have keyboard navigation for operating the files +screen. + +Source: task-072 + +Epic: epic-000 diff --git a/schist_core/schist_fakes/src/drip.rs b/schist_core/schist_fakes/src/drip.rs index c2aa7a0..f7d0222 100644 --- a/schist_core/schist_fakes/src/drip.rs +++ b/schist_core/schist_fakes/src/drip.rs @@ -7,6 +7,12 @@ pub fn make_fake_drip(id: i32) -> Drip { pub fn make_fake_drip_builder(id: i32) -> DripBuilder { let mut builder = DripBuilder::default(); - builder.id(id).amount(0).bucket_id(0).date(DateUtc::now()); + builder + .id(id) + .amount(0) + .bucket_id(0) + .date(DateUtc::now()) + .inserted_at_unix_seconds(0) + .pipe_id(0); builder } diff --git a/schist_core/schist_fakes/src/pipe.rs b/schist_core/schist_fakes/src/pipe.rs index 765ad78..df77396 100644 --- a/schist_core/schist_fakes/src/pipe.rs +++ b/schist_core/schist_fakes/src/pipe.rs @@ -11,7 +11,8 @@ pub fn make_fake_pipe_builder(id: i32) -> PipeBuilder { .id(id) .amount(0) .bucket_id(0) - .period(BudgetPeriodUnit::Day) + .period_unit(BudgetPeriodUnit::Day) + .period_length(1) .start_date(DateUtc::now()); builder } diff --git a/schist_core/schist_models/migrations/2024-08-31-084439_initial_setup/up.sql b/schist_core/schist_models/migrations/2024-08-31-084439_initial_setup/up.sql index 275f280..b37c626 100644 --- a/schist_core/schist_models/migrations/2024-08-31-084439_initial_setup/up.sql +++ b/schist_core/schist_models/migrations/2024-08-31-084439_initial_setup/up.sql @@ -66,7 +66,8 @@ CREATE TABLE pipes( id INTEGER NOT NULL PRIMARY KEY, amount INTEGER NOT NULL, bucket_id INTEGER NOT NULL, - period TEXT NOT NULL, + period_length INTEGER NOT NULL, + period_unit TEXT NOT NULL, start_date TEXT NOT NULL, FOREIGN KEY (bucket_id) REFERENCES buckets (id) diff --git a/schist_core/schist_models/migrations/2025-08-15-090859_dummy_data/down.sql b/schist_core/schist_models/migrations/2025-08-15-090859_dummy_data/down.sql deleted file mode 100644 index 8050572..0000000 --- a/schist_core/schist_models/migrations/2025-08-15-090859_dummy_data/down.sql +++ /dev/null @@ -1,39 +0,0 @@ -DELETE FROM accounts - WHERE id=0 - AND name="Nationwide bank account" - AND opening_balance=0 - AND opening_date="2024-01-01"; - -DELETE FROM accounts - WHERE id=1 - AND name="cash account" - AND opening_balance=0 - AND opening_date="2024-01-01"; - -DELETE FROM accounts - WHERE id=2 - AND name="YBS savings account" - AND opening_balance=0 - AND opening_date="2024-01-01"; - -DELETE FROM buckets - WHERE id=0 - AND balance IS NULL - AND balance_cache_key IS NULL - AND bucket_group="normal expenses" - AND name="groceries"; - -DELETE FROM buckets - WHERE id=1 - AND balance IS NULL - AND balance_cache_key IS NULL - AND bucket_group="luxuries" - AND name="bevvy"; - -DELETE FROM buckets - WHERE id=2 - AND balance IS NULL - AND balance_cache_key IS NULL - AND bucket_group="income" - AND name="salary"; - diff --git a/schist_core/schist_models/migrations/2025-08-15-090859_dummy_data/up.sql b/schist_core/schist_models/migrations/2025-08-15-090859_dummy_data/up.sql deleted file mode 100644 index f5c97d0..0000000 --- a/schist_core/schist_models/migrations/2025-08-15-090859_dummy_data/up.sql +++ /dev/null @@ -1,14 +0,0 @@ -INSERT INTO accounts - (id, name, opening_balance, opening_date) - VALUES - (0, "Nationwide bank account", 0, "2024-01-01"), - (1, "cash account", 0, "2024-01-01"), - (2, "YBS savings account", 0, "2024-01-01"); - -INSERT INTO buckets - (id, balance, balance_cache_key, bucket_group, name) - VALUES - (0, NULL, NULL, "normal expenses", "groceries"), - (1, NULL, NULL, "luxuries", "bevvy"), - (2, NULL, NULL, "income", "salary"); - diff --git a/schist_core/schist_models/src/bucket.rs b/schist_core/schist_models/src/bucket.rs index 1a78628..a28aa8f 100644 --- a/schist_core/schist_models/src/bucket.rs +++ b/schist_core/schist_models/src/bucket.rs @@ -2,6 +2,8 @@ use derive_builder::Builder; use diesel::prelude::*; use serde::{Deserialize, Serialize}; +use crate::{Drip, Transaction}; + #[derive( Builder, Clone, @@ -19,7 +21,16 @@ pub struct Bucket { pub id: i32, pub balance: Option, pub balance_cache_key: Option, - #[column_name = "bucket_group"] + #[diesel(column_name = "bucket_group")] pub group: String, pub name: String, } + +impl Bucket { + pub fn is_empty(&self, drips: &[Drip], transactions: &[Transaction]) -> bool { + drips.iter().all(|drip| drip.bucket_id != self.id) + && transactions + .iter() + .all(|t| t.bucket_id.is_none_or(|id| id != self.id)) + } +} diff --git a/schist_core/schist_models/src/pipe.rs b/schist_core/schist_models/src/pipe.rs index fb66a20..9354c20 100644 --- a/schist_core/schist_models/src/pipe.rs +++ b/schist_core/schist_models/src/pipe.rs @@ -22,6 +22,7 @@ pub struct Pipe { pub id: i32, pub amount: i32, pub bucket_id: i32, - pub period: BudgetPeriodUnit, + pub period_length: i32, + pub period_unit: BudgetPeriodUnit, pub start_date: DateUtc, } diff --git a/schist_core/schist_models/src/schema.rs b/schist_core/schist_models/src/schema.rs index 3799479..d16e636 100644 --- a/schist_core/schist_models/src/schema.rs +++ b/schist_core/schist_models/src/schema.rs @@ -56,7 +56,8 @@ diesel::table! { id -> Integer, amount -> Integer, bucket_id -> Integer, - period -> Text, + period_length -> Integer, + period_unit -> Text, start_date -> Text, } } diff --git a/schist_core/schist_queries/src/clear.rs b/schist_core/schist_queries/src/clear.rs index 04d22d9..a5a82f3 100644 --- a/schist_core/schist_queries/src/clear.rs +++ b/schist_core/schist_queries/src/clear.rs @@ -2,17 +2,16 @@ use anyhow::Result; use diesel::SqliteConnection; use crate::{ - accounts::delete_all_accounts, bucket_transfers::delete_all_bucket_transfers, - buckets::delete_all_buckets, drips::delete_all_drips, pipes::delete_all_pipes, - transactions::delete_all_transactions, + account_transfers::delete_all_account_transfers, accounts::delete_all_accounts, bucket_transfers::delete_all_bucket_transfers, buckets::delete_all_buckets, drips::delete_all_drips, pipes::delete_all_pipes, transactions::delete_all_transactions }; pub fn clear(connection: &mut SqliteConnection) -> Result<()> { - delete_all_accounts(connection)?; + delete_all_account_transfers(connection)?; delete_all_drips(connection)?; - delete_all_buckets(connection)?; - delete_all_bucket_transfers(connection)?; - delete_all_transactions(connection)?; delete_all_pipes(connection)?; + delete_all_transactions(connection)?; + delete_all_accounts(connection)?; + delete_all_bucket_transfers(connection)?; + delete_all_buckets(connection)?; Ok(()) } diff --git a/schist_core/schist_queries/src/utils/mod.rs b/schist_core/schist_queries/src/utils/mod.rs index 6b47b48..3a81c8c 100644 --- a/schist_core/schist_queries/src/utils/mod.rs +++ b/schist_core/schist_queries/src/utils/mod.rs @@ -2,7 +2,5 @@ mod calculate_bucket_balance; mod find_by_id_or; mod sum_drips_for_bucket; +use find_by_id_or::find_by_id_or; use sum_drips_for_bucket::sum_drips_for_bucket; - -pub use calculate_bucket_balance::calculate_bucket_balance; -pub use find_by_id_or::find_by_id_or; diff --git a/schist_core/schist_queries/tests/category_transfers.rs b/schist_core/schist_queries/tests/category_transfers.rs index 13b9866..f7c75a7 100644 --- a/schist_core/schist_queries/tests/category_transfers.rs +++ b/schist_core/schist_queries/tests/category_transfers.rs @@ -184,6 +184,7 @@ fn given_bucket_transfers_when_sum_quantity_per_to_bucket_id_then_returns_sum_pe make_fake_bucket_transfer_builder(2) .from_bucket_id(1) .to_bucket_id(0) + .amount(4) .build() .unwrap(), ]; @@ -196,7 +197,5 @@ fn given_bucket_transfers_when_sum_quantity_per_to_bucket_id_then_returns_sum_pe assert!(sums.is_ok()); let mut sums = sums.unwrap(); sums.sort_by_key(|sum| sum.0); - assert_eq!(buckets.len(), sums.len()); - assert_eq!(4, sums[0].1); - assert_eq!(3, sums[1].1); + assert_eq!([(0, 4), (1, 3)], sums.as_slice()); } diff --git a/schist_desktop_gui/Cargo.toml b/schist_desktop_gui/Cargo.toml index 35e6521..0ee5764 100644 --- a/schist_desktop_gui/Cargo.toml +++ b/schist_desktop_gui/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] +actualbudget_to_schist_transformer = { workspace = true } anyhow = { workspace = true } diesel = { workspace = true } diesel_migrations = { workspace = true } @@ -11,6 +12,7 @@ dirs = { workspace = true } iced = { workspace = true } itertools = { workspace = true } libsqlite3-sys = { workspace = true } +rfd = { workspace = true } schist_fakes = { workspace = true } schist_models = { workspace = true } schist_queries = { workspace = true } diff --git a/schist_desktop_gui/src/connection.rs b/schist_desktop_gui/src/connection.rs deleted file mode 100644 index 5a88083..0000000 --- a/schist_desktop_gui/src/connection.rs +++ /dev/null @@ -1,20 +0,0 @@ -use std::{fs::create_dir_all, path::Path}; - -use anyhow::Context; -use diesel::{Connection, SqliteConnection}; - -const SCHIST_DATA_DIR: &'static str = "schist"; -const SCHIST_DATABASE_FILENAME: &'static str = "schist.sqlite"; - -pub fn establish_connection() -> anyhow::Result { - let data_dir = dirs::data_dir().context("Failed to get local app data directory")?; - let schist_local_dir = data_dir.join(Path::new(SCHIST_DATA_DIR)); - create_dir_all(&schist_local_dir) - .context(format!("Failed to create local files directory at {}", schist_local_dir.display()))?; - let db_path = schist_local_dir.join(Path::new(SCHIST_DATABASE_FILENAME)); - let db_path = db_path - .to_str() - .context("Failed to construct database path")?; - Ok(SqliteConnection::establish(db_path) - .with_context(|| format!("Failed to connect to database at {}", db_path))?) -} diff --git a/schist_desktop_gui/src/database_connection.rs b/schist_desktop_gui/src/database_connection.rs new file mode 100644 index 0000000..99fbe2d --- /dev/null +++ b/schist_desktop_gui/src/database_connection.rs @@ -0,0 +1,71 @@ +use std::{fmt, path::PathBuf}; + +use anyhow::Context; +use diesel::{Connection, SqliteConnection}; +use diesel_migrations::MigrationHarness; +use schist_models::migrations::MIGRATIONS; + +pub struct DatabaseConnection { + pub connection: diesel::SqliteConnection, + pub file_path: PathBuf, +} + +impl DatabaseConnection { + pub fn establish(file_path: PathBuf) -> DatabaseConnectionResult { + DatabaseConnectionResult { + connection: Self::establish_sqlite_connection(&file_path), + file_path: file_path, + } + } + + fn establish_sqlite_connection(file_path: &PathBuf) -> anyhow::Result { + let file_path_str = file_path + .to_str() + .with_context(|| format!("Failed to get database path from {:?}", file_path))?; + let mut connection = diesel::SqliteConnection::establish(&file_path_str) + .with_context(|| format!("Failed to connect to database at {}", file_path_str))?; + connection + .run_pending_migrations(MIGRATIONS) + .map_err(anyhow::Error::from_boxed) + .with_context(|| { + format!( + "Failed to run pending migrations on database at {}", + file_path_str + ) + })?; + Ok(connection) + } +} + +impl fmt::Debug for DatabaseConnection { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SqliteConnection") + .field("file_path", &self.file_path) + .finish() + } +} + +pub struct DatabaseConnectionResult { + pub connection: anyhow::Result, + pub file_path: PathBuf, +} + +impl DatabaseConnectionResult { + pub fn ok(self) -> Result { + self.try_into() + } +} + +impl TryFrom for DatabaseConnection { + type Error = (PathBuf, anyhow::Error); + + fn try_from(result: DatabaseConnectionResult) -> Result { + result + .connection + .map(|connection| DatabaseConnection { + connection, + file_path: result.file_path.clone(), + }) + .map_err(|err| (result.file_path, err)) + } +} diff --git a/schist_desktop_gui/src/dialog.rs b/schist_desktop_gui/src/dialog.rs new file mode 100644 index 0000000..e2704be --- /dev/null +++ b/schist_desktop_gui/src/dialog.rs @@ -0,0 +1,7 @@ +mod import_from_actualbudget_dialog; +mod new_file_dialog; +mod pick_file_dialog; + +pub use import_from_actualbudget_dialog::import_from_actualbudget_dialog; +pub use new_file_dialog::new_file_dialog; +pub use pick_file_dialog::pick_file_dialog; diff --git a/schist_desktop_gui/src/dialog/import_from_actualbudget_dialog.rs b/schist_desktop_gui/src/dialog/import_from_actualbudget_dialog.rs new file mode 100644 index 0000000..10d4ec6 --- /dev/null +++ b/schist_desktop_gui/src/dialog/import_from_actualbudget_dialog.rs @@ -0,0 +1,26 @@ +use actualbudget_to_schist_transformer::{ + actualbudget_state::read_actualbudget_state, schist_state::SchistState, + transform_state::transform_state, +}; +use anyhow::anyhow; +use diesel::{Connection, SqliteConnection}; + +pub fn import_from_actualbudget_dialog( +) -> Option> { + let path = rfd::FileDialog::new() + .add_filter("SQLite database", &["sqlite"]) + .set_title("Import Actualbudget database") + .pick_file(); + + path.as_ref() + .map(|path| import_from_actualbudget(path).map_err(|err| (path.clone(), err))) +} + +fn import_from_actualbudget(path: &std::path::PathBuf) -> anyhow::Result { + let path = path + .to_str() + .ok_or_else(|| anyhow!("Failed to stringify path: {:?}", path))?; + SqliteConnection::establish(path) + .map(|mut conn| read_actualbudget_state(&mut conn))? + .map(transform_state)? +} diff --git a/schist_desktop_gui/src/dialog/new_file_dialog.rs b/schist_desktop_gui/src/dialog/new_file_dialog.rs new file mode 100644 index 0000000..b950f68 --- /dev/null +++ b/schist_desktop_gui/src/dialog/new_file_dialog.rs @@ -0,0 +1,15 @@ +use crate::{database_connection::DatabaseConnectionResult, DatabaseConnection}; + +pub fn new_file_dialog

(starting_directory: Option

) -> Option +where + P: AsRef, +{ + let mut file_dialog = rfd::FileDialog::new() + .add_filter("SQLite database", &["sqlite"]) + .set_file_name("schist.sqlite") + .set_title("New file"); + if let Some(starting_directory) = starting_directory { + file_dialog = file_dialog.set_directory(starting_directory); + } + file_dialog.save_file().map(DatabaseConnection::establish) +} diff --git a/schist_desktop_gui/src/dialog/pick_file_dialog.rs b/schist_desktop_gui/src/dialog/pick_file_dialog.rs new file mode 100644 index 0000000..53d36c6 --- /dev/null +++ b/schist_desktop_gui/src/dialog/pick_file_dialog.rs @@ -0,0 +1,14 @@ +use crate::{database_connection::DatabaseConnectionResult, DatabaseConnection}; + +pub fn pick_file_dialog

(starting_directory: Option

) -> Option +where + P: AsRef, +{ + let mut file_dialog = rfd::FileDialog::new() + .add_filter("SQLite database", &["sqlite"]) + .set_title("Open file"); + if let Some(starting_directory) = starting_directory { + file_dialog = file_dialog.set_directory(starting_directory); + } + file_dialog.pick_file().map(DatabaseConnection::establish) +} diff --git a/schist_desktop_gui/src/gui.rs b/schist_desktop_gui/src/gui.rs index 099ff74..b7405c6 100644 --- a/schist_desktop_gui/src/gui.rs +++ b/schist_desktop_gui/src/gui.rs @@ -8,7 +8,8 @@ use schist_queries::{accounts::get_all_accounts, buckets::get_all_buckets}; use crate::{ config::Config, - gui::screens::{main_screen, MainScreen, Screen}, + gui::screens::{files_screen, main_screen, FilesScreen, MainScreen, Screen}, + paths::get_file_paths, shortcut::KeyBind, traits::{Component, Viewable}, }; @@ -16,6 +17,7 @@ use crate::{ #[derive(Clone, Debug)] pub enum Message { Event(iced::Event), + FilesScreenMessage(files_screen::Message), MainScreenMessage(main_screen::Message), RefetchedAccounts(FetchResult>), RefetchedBuckets(FetchResult>), @@ -38,38 +40,31 @@ impl FetchError { pub struct Gui { active_screen: Screen, - buckets: Vec, + connection: Option, + files_screen: FilesScreen, main_screen: MainScreen, config: Config, } impl Gui { - pub fn new(connection: &mut SqliteConnection) -> (Self, iced::Task) { - let buckets = Vec::new(); - let accounts = Vec::new(); - let mut gui = Self { - active_screen: Screen::Main, - buckets: buckets.clone(), - main_screen: MainScreen::new(buckets, accounts), + pub fn new() -> (Self, iced::Task) { + let file_paths = get_file_paths(); + let gui = Self { + active_screen: Screen::FilesScreen, + connection: None, + files_screen: FilesScreen::new(&file_paths), + main_screen: MainScreen::new(), config: Config::default(), }; - - let buckets = get_all_buckets(connection).map_err(FetchError::new); - let accounts = get_all_accounts(connection).map_err(FetchError::new); - - let tasks = iced::Task::batch(vec![ - gui.update(Message::RefetchedBuckets(buckets)), - gui.update(Message::RefetchedAccounts(accounts)), - ]); - - (gui, tasks) + (gui, iced::Task::none()) } } impl Gui { pub fn view<'a>(&'a self) -> Element<'a, Message> { match self.active_screen { - Screen::Main => self.main_screen.view().map(Message::MainScreenMessage), + Screen::FilesScreen => self.files_screen.view().map(Message::FilesScreenMessage), + Screen::MainScreen => self.main_screen.view().map(Message::MainScreenMessage), } } @@ -126,7 +121,6 @@ impl Gui { Message::RefetchedBuckets(buckets) => { match buckets { Ok(buckets) => { - self.buckets = buckets.clone(); self.main_screen .update(main_screen::Message::SetBuckets(buckets)); } @@ -144,6 +138,20 @@ impl Gui { } iced::Task::none() } + Message::FilesScreenMessage(message) => match self.files_screen.update(message) { + files_screen::Action::None => iced::Task::none(), + files_screen::Action::OpenedFile(sqlite_connection) => { + let mut connection = sqlite_connection.connection; + let buckets = get_all_buckets(&mut connection).map_err(FetchError::new); + let accounts = get_all_accounts(&mut connection).map_err(FetchError::new); + self.connection = Some(connection); + self.active_screen = Screen::MainScreen; + iced::Task::batch(vec![ + self.update(Message::RefetchedBuckets(buckets)), + self.update(Message::RefetchedAccounts(accounts)), + ]) + } + }, } } } diff --git a/schist_desktop_gui/src/gui/components.rs b/schist_desktop_gui/src/gui/components.rs index fc3c838..a242b0b 100644 --- a/schist_desktop_gui/src/gui/components.rs +++ b/schist_desktop_gui/src/gui/components.rs @@ -9,7 +9,7 @@ pub mod transactions_view; pub use balances_view::BalancesView; pub use bucket_name_and_balance::BucketNameAndBalance; pub use buckets_view::BucketsView; -pub use button::{active_button, inactive_button}; +pub use button::{active_button, inactive_button, panel_button}; pub use navigation::Navigation; pub use text::Text; pub use transactions_view::TransactionsView; diff --git a/schist_desktop_gui/src/gui/components/balances_view/view_balances_view.rs b/schist_desktop_gui/src/gui/components/balances_view/view_balances_view.rs index e8a1e46..6a64508 100644 --- a/schist_desktop_gui/src/gui/components/balances_view/view_balances_view.rs +++ b/schist_desktop_gui/src/gui/components/balances_view/view_balances_view.rs @@ -1,6 +1,7 @@ use iced::widget::{column, row, Container}; use iced::{alignment, Element, Length}; +use crate::gui::components::Text; use crate::gui::components::{balances_view::Message, BalancesView}; use crate::style::SPACING_LG; use crate::traits::Viewable; @@ -10,7 +11,7 @@ pub fn view_balances_view<'a>(balances_view: &'a BalancesView) -> Element<'a, Me .navigation .view() .map(Message::NavigationMessage); - let main_content = column![iced::widget::text(balances_view.greeting.clone())]; + let main_content = column![Text::default(&balances_view.greeting).as_element()]; row![ Container::new(navigation).width(Length::FillPortion(1)), Container::new(main_content).width(Length::FillPortion(3)), diff --git a/schist_desktop_gui/src/gui/components/balances_view/view_group_names.rs b/schist_desktop_gui/src/gui/components/balances_view/view_group_names.rs index 271e76c..18ddf0b 100644 --- a/schist_desktop_gui/src/gui/components/balances_view/view_group_names.rs +++ b/schist_desktop_gui/src/gui/components/balances_view/view_group_names.rs @@ -6,7 +6,7 @@ use crate::gui::components::Text; pub fn view_group_names(buckets: Vec) -> Vec { buckets .iter() - .map(|b| b.group.clone()) + .map(|b| b.group.as_str()) .unique() .map(Text::new) .collect() @@ -96,23 +96,23 @@ mod test { ]; let group_names = view_group_names(buckets); assert_eq!(17, group_names.len()); - assert_eq!(group_names[0], Text::new(String::from("hydrogen"))); - assert_eq!(group_names[1], Text::new(String::from("helium"))); - assert_eq!(group_names[2], Text::new(String::from("lithium"))); - assert_eq!(group_names[3], Text::new(String::from("beryllium"))); - assert_eq!(group_names[4], Text::new(String::from("boron"))); - assert_eq!(group_names[5], Text::new(String::from("carbon"))); - assert_eq!(group_names[6], Text::new(String::from("nitrogen"))); - assert_eq!(group_names[7], Text::new(String::from("oxygen"))); - assert_eq!(group_names[8], Text::new(String::from("flourine"))); - assert_eq!(group_names[9], Text::new(String::from("neon"))); - assert_eq!(group_names[10], Text::new(String::from("magnesium"))); - assert_eq!(group_names[11], Text::new(String::from("aluminium"))); - assert_eq!(group_names[12], Text::new(String::from("silicon"))); - assert_eq!(group_names[13], Text::new(String::from("phosphorus"))); - assert_eq!(group_names[14], Text::new(String::from("sulphur"))); - assert_eq!(group_names[15], Text::new(String::from("chlorine"))); - assert_eq!(group_names[16], Text::new(String::from("argon"))); + assert_eq!(group_names[0], Text::new("hydrogen")); + assert_eq!(group_names[1], Text::new("helium")); + assert_eq!(group_names[2], Text::new("lithium")); + assert_eq!(group_names[3], Text::new("beryllium")); + assert_eq!(group_names[4], Text::new("boron")); + assert_eq!(group_names[5], Text::new("carbon")); + assert_eq!(group_names[6], Text::new("nitrogen")); + assert_eq!(group_names[7], Text::new("oxygen")); + assert_eq!(group_names[8], Text::new("flourine")); + assert_eq!(group_names[9], Text::new("neon")); + assert_eq!(group_names[10], Text::new("magnesium")); + assert_eq!(group_names[11], Text::new("aluminium")); + assert_eq!(group_names[12], Text::new("silicon")); + assert_eq!(group_names[13], Text::new("phosphorus")); + assert_eq!(group_names[14], Text::new("sulphur")); + assert_eq!(group_names[15], Text::new("chlorine")); + assert_eq!(group_names[16], Text::new("argon")); } #[test] @@ -134,8 +134,8 @@ mod test { let group_names = view_group_names(buckets); assert_eq!( vec![ - Text::new(String::from("hydrogen")), - Text::new(String::from("helium")), + Text::new("hydrogen"), + Text::new("helium"), ], group_names ); diff --git a/schist_desktop_gui/src/gui/components/bucket_name_and_balance.rs b/schist_desktop_gui/src/gui/components/bucket_name_and_balance.rs index db3fc2e..24a3952 100644 --- a/schist_desktop_gui/src/gui/components/bucket_name_and_balance.rs +++ b/schist_desktop_gui/src/gui/components/bucket_name_and_balance.rs @@ -1,10 +1,7 @@ -use iced::{ - widget::{row, text}, - Element, -}; +use iced::{widget::row, Element}; use schist_models::Bucket; -use crate::gui::components::navigation; +use crate::gui::components::{navigation, Text}; #[derive(Clone, Debug, PartialEq)] pub struct BucketNameAndBalance { @@ -20,8 +17,8 @@ impl BucketNameAndBalance { impl<'a> Into>> for BucketNameAndBalance { fn into(self) -> Element<'a, navigation::Message> { row![ - text(self.bucket.name.clone()), - text(self.bucket.balance.unwrap_or(0)) + Text::new(&self.bucket.name).as_element(), + Text::new(&self.bucket.balance.unwrap_or(0).to_string()).as_element(), ] .into() } diff --git a/schist_desktop_gui/src/gui/components/buckets_view.rs b/schist_desktop_gui/src/gui/components/buckets_view.rs index bb4dbb4..e513a35 100644 --- a/schist_desktop_gui/src/gui/components/buckets_view.rs +++ b/schist_desktop_gui/src/gui/components/buckets_view.rs @@ -6,7 +6,7 @@ use iced::{ use schist_models::Bucket; use crate::{ - gui::components::{navigation, BucketNameAndBalance, Navigation}, + gui::components::{navigation, BucketNameAndBalance, Navigation, Text}, style::SPACING_LG, traits::{Component, Viewable}, }; @@ -53,7 +53,7 @@ impl<'a> Viewable<'a, Message> for BucketsView { Message: 'a, { let navigation = self.navigation.view().map(Message::NavigationMessage); - let main_content = column![iced::widget::text(self.greeting.clone())]; + let main_content = column![Text::default(&self.greeting).as_element()]; row![ Container::new(navigation).width(Length::FillPortion(1)), Container::new(main_content).width(Length::FillPortion(3)), diff --git a/schist_desktop_gui/src/gui/components/button.rs b/schist_desktop_gui/src/gui/components/button.rs index bc9e5f9..e071f33 100644 --- a/schist_desktop_gui/src/gui/components/button.rs +++ b/schist_desktop_gui/src/gui/components/button.rs @@ -1,5 +1,7 @@ use iced::{widget::Button, Element}; +use crate::style::*; + pub fn active_button<'a, Content: Into>, Message>( content: Content, ) -> Button<'a, Message> { @@ -22,3 +24,22 @@ pub fn inactive_button<'a, Content: Into>, Message>( .on_press(on_press) .width(iced::Length::Fill) } + +pub fn panel_button<'a, Content: Into>, Message>( + content: Content, + on_press: Message, +) -> Button<'a, Message> { + iced::widget::button(content) + .on_press(on_press) + .style(|theme: &iced::Theme, _status| iced::widget::button::Style { + background: None, + border: iced::Border { + color: theme.extended_palette().primary.base.text, + radius: iced::border::Radius::new(0), + width: 2.0, + }, + ..Default::default() + }) + .width(iced::Length::Fill) + .padding(iced::Padding::new(SPACING_MD.into())) +} diff --git a/schist_desktop_gui/src/gui/components/text.rs b/schist_desktop_gui/src/gui/components/text.rs index d334de3..90cd7bc 100644 --- a/schist_desktop_gui/src/gui/components/text.rs +++ b/schist_desktop_gui/src/gui/components/text.rs @@ -1,22 +1,100 @@ -use iced::Element; - -use crate::gui::components::navigation; +use crate::style::*; #[derive(Clone, Debug, PartialEq)] pub struct Text { pub content: String, + colour: Option, + size: Option, + strength: Option, +} + +#[derive(Clone, Debug, PartialEq)] +enum Colour { + Danger, + Primary, +} + +#[derive(Clone, Debug, PartialEq)] +enum Strength { + Base, + Weak, +} + +#[derive(Clone, Debug, PartialEq)] +enum Size { + Small, + Base, } impl Text { - pub fn new(group_name: String) -> Self { + pub fn default(content: &str) -> Self { + Self { + content: String::from(content), + colour: Some(Colour::Primary), + size: Some(Size::Base), + strength: Some(Strength::Base), + } + } + + pub fn new(content: &str) -> Self { + Self { + content: String::from(content), + colour: None, + size: None, + strength: None, + } + } + + pub fn as_element<'a, Message>(self) -> iced::Element<'a, Message> { + >>::into(self) + } + + pub fn small(&self) -> Self { + Self { + size: Some(Size::Small), + ..self.clone() + } + } + + pub fn danger(&self) -> Self { + Self { + colour: Some(Colour::Danger), + ..self.clone() + } + } + + pub fn weak(&self) -> Self { Self { - content: group_name.clone(), + strength: Some(Strength::Weak), + ..self.clone() } } } -impl<'a> Into>> for Text { - fn into(self) -> Element<'a, navigation::Message> { - iced::widget::text(self.content.clone()).into() +impl<'a, Message> Into> for Text { + fn into(self) -> iced::Element<'a, Message> { + iced::widget::text(self.content.clone()) + .size(match self.size { + Some(Size::Small) => TEXT_SIZE_SM, + Some(Size::Base) | None => TEXT_SIZE_BASE, + }) + .style(move |theme: &iced::Theme| iced::widget::text::Style { + color: match (&self.colour, &self.strength) { + (Some(Colour::Danger), Some(Strength::Base) | None) => { + Some(theme.extended_palette().danger.base.text) + } + (Some(Colour::Danger), Some(Strength::Weak)) => { + Some(theme.extended_palette().danger.weak.text) + } + (Some(Colour::Primary), Some(Strength::Base) | None) => { + Some(theme.extended_palette().primary.base.text) + } + (Some(Colour::Primary), Some(Strength::Weak)) => { + Some(theme.extended_palette().primary.weak.text) + } + (None, _) => None, + }, + }) + .into() } } diff --git a/schist_desktop_gui/src/gui/components/transactions_view.rs b/schist_desktop_gui/src/gui/components/transactions_view.rs index 88532a9..1956d38 100644 --- a/schist_desktop_gui/src/gui/components/transactions_view.rs +++ b/schist_desktop_gui/src/gui/components/transactions_view.rs @@ -45,7 +45,7 @@ impl TransactionsView { impl<'a> Viewable<'a, Message> for TransactionsView { fn view(&'a self) -> Element<'a, Message> { let navigation = self.navigation.view().map(Message::NavigationMessage); - let main_content = column![iced::widget::text(self.greeting.clone())]; + let main_content = column![Text::default(&self.greeting).as_element()]; row![ Container::new(navigation).width(Length::FillPortion(1)), Container::new(main_content).width(Length::FillPortion(3)), @@ -113,6 +113,6 @@ fn make_greeting(account_name: Text) -> String { fn view_account_names(accounts: Vec) -> Vec { accounts .iter() - .map(|acc| Text::new(acc.name.clone())) + .map(|acc| Text::new(acc.name.as_str())) .collect() } diff --git a/schist_desktop_gui/src/gui/screens.rs b/schist_desktop_gui/src/gui/screens.rs index 3ae97dd..abca2b6 100644 --- a/schist_desktop_gui/src/gui/screens.rs +++ b/schist_desktop_gui/src/gui/screens.rs @@ -1,9 +1,12 @@ +pub mod files_screen; pub mod main_screen; +pub use files_screen::FilesScreen; pub use main_screen::MainScreen; #[derive(Default)] pub enum Screen { #[default] - Main, + FilesScreen, + MainScreen, } diff --git a/schist_desktop_gui/src/gui/screens/files_screen.rs b/schist_desktop_gui/src/gui/screens/files_screen.rs new file mode 100644 index 0000000..68a9556 --- /dev/null +++ b/schist_desktop_gui/src/gui/screens/files_screen.rs @@ -0,0 +1,36 @@ +mod update_files_screen; +mod view_files_screen; + +use std::{fmt::Debug, path::PathBuf}; + +use crate::DatabaseConnection; + +pub struct FilesScreen { + new_file_err: Option<(PathBuf, String)>, + file_paths: Vec, + open_file_err: Option<(PathBuf, String)>, +} + +#[derive(Clone, Debug)] +pub enum Message { + ImportFromActualbudget, + NewFile, + OpenFile(std::path::PathBuf), + PickFile, +} + +#[derive(Debug)] +pub enum Action { + None, + OpenedFile(DatabaseConnection), +} + +impl<'a> FilesScreen { + pub fn new(file_paths: &'a [PathBuf]) -> Self { + Self { + file_paths: file_paths.to_vec(), + new_file_err: None, + open_file_err: None, + } + } +} diff --git a/schist_desktop_gui/src/gui/screens/files_screen/update_files_screen.rs b/schist_desktop_gui/src/gui/screens/files_screen/update_files_screen.rs new file mode 100644 index 0000000..9c45d6e --- /dev/null +++ b/schist_desktop_gui/src/gui/screens/files_screen/update_files_screen.rs @@ -0,0 +1,125 @@ +use actualbudget_to_schist_transformer::schist_state::{export_schist_state, SchistState}; +use itertools::Itertools; + +use crate::{ + database_connection::DatabaseConnectionResult, + dialog::{import_from_actualbudget_dialog, new_file_dialog, pick_file_dialog}, + paths::get_schist_database_directory, + traits::Component, + DatabaseConnection, +}; + +use super::{Action, FilesScreen, Message}; + +impl<'a> Component<'a, Message, Action> for FilesScreen { + fn update(&mut self, message: Message) -> Action { + match message { + Message::NewFile => match self.new_file() { + Some(Ok(file)) => Action::OpenedFile(file), + Some(Err(_)) | None => Action::None, + }, + + Message::OpenFile(file_path) => self + .open_file(file_path) + .map_or(Action::None, Action::OpenedFile), + + Message::PickFile => match self.pick_file() { + Some(Ok(file)) => Action::OpenedFile(file), + Some(Err(_)) | None => Action::None, + }, + + Message::ImportFromActualbudget => match self.import_from_actualbudget() { + Some(Ok(file)) => Action::OpenedFile(file), + Some(Err(_)) | None => Action::None, + }, + } + } +} + +impl FilesScreen { + fn pick_file( + &mut self, + ) -> Option> { + pick_file_dialog(get_schist_database_directory().ok()).map(|db_connection_result| { + db_connection_result.ok().inspect_err(|(path, err)| { + self.open_file_err = Some((path.clone(), err.to_string())); + rfd::MessageDialog::new() + .set_title("Failed to import from Actualbudget") + .set_level(rfd::MessageLevel::Error) + .set_description(format!("{}", err.chain().join("\n | "))) + .show(); + }) + }) + } + + fn open_file( + &mut self, + file_path: std::path::PathBuf, + ) -> Result { + DatabaseConnection::establish(file_path.clone()) + .ok() + .inspect_err(|(_path, err)| { + self.open_file_err = Some((file_path, err.to_string())); + rfd::MessageDialog::new() + .set_title("Failed to import from Actualbudget") + .set_level(rfd::MessageLevel::Error) + .set_description(format!("{}", err.chain().join("\n | "))) + .show(); + }) + } + + fn new_file( + &mut self, + ) -> Option> { + new_file_dialog(get_schist_database_directory().ok()).map(|result| { + result.ok().inspect_err(|(path, err)| { + self.new_file_err = Some((path.clone(), err.to_string())); + rfd::MessageDialog::new() + .set_title("Failed to import from Actualbudget") + .set_level(rfd::MessageLevel::Error) + .set_description(format!("{}", err.chain().join("\n | "))) + .show(); + }) + }) + } + + fn import_from_actualbudget(&mut self) -> Option> { + match import_from_actualbudget_dialog() { + Some(Ok(state)) => self.new_file_with_state(&state), + Some(Err((path, err))) => Some({ + self.open_file_err = Some((path, err.to_string())); + rfd::MessageDialog::new() + .set_title("Failed to import from Actualbudget") + .set_level(rfd::MessageLevel::Error) + .set_description(format!("{}", err.chain().join("\n | "))) + .show(); + Err(err) + }), + None => None, + } + } + + fn new_file_with_state( + &mut self, + state: &SchistState, + ) -> Option> { + match new_file_dialog(get_schist_database_directory().ok()) + .map(DatabaseConnectionResult::ok) + { + Some(Ok(mut file)) => Some({ + export_schist_state(state, &mut file.connection).ok()?; + Ok(file) + }), + Some(Err((path, err))) => Some({ + self.new_file_err = Some((path, err.to_string())); + rfd::MessageDialog::new() + .set_title("Failed to import from Actualbudget") + .set_level(rfd::MessageLevel::Error) + .set_description(format!("{}", err.chain().join("\n | "))) + .show(); + Err(err) + }), + None => None, + } + } +} diff --git a/schist_desktop_gui/src/gui/screens/files_screen/view_files_screen.rs b/schist_desktop_gui/src/gui/screens/files_screen/view_files_screen.rs new file mode 100644 index 0000000..3cbd939 --- /dev/null +++ b/schist_desktop_gui/src/gui/screens/files_screen/view_files_screen.rs @@ -0,0 +1,106 @@ +use std::path::PathBuf; + +use iced::widget::column; + +use crate::{ + gui::components::{panel_button, Text}, + style::*, + traits::Viewable, +}; + +use super::{FilesScreen, Message}; + +impl<'a> Viewable<'a, Message> for FilesScreen { + fn view(&'a self) -> iced::Element<'a, Message> { + let mut cols = Vec::new(); + if let Some(col) = self.view_new_file_err() { + cols.push(col); + }; + if let Some(col) = self.view_open_file_err() { + cols.push(col); + }; + cols.push(self.view_new_file_button()); + cols.push(self.view_open_file_button()); + cols.push(self.view_import_from_actualbudget_button()); + cols.append(&mut self.view_files()); + column(cols).padding(SPACING_LG).spacing(SPACING_MD).into() + } +} + +impl<'a> FilesScreen { + fn view_new_file_err(&'a self) -> Option> { + self.new_file_err.as_ref().map(|(path, err)| { + let msg = format!("Failed to open new file {:#?}: {:#?}", path, err); + Text::new(&msg).danger().into() + }) + } + + fn view_open_file_err(&'a self) -> Option> { + self.open_file_err + .as_ref() + .filter(|(file_path, _err)| !self.file_paths.iter().any(|fp| *fp == *file_path)) + .map(|(file_path, err)| { + let msg = format!("Failed to open new file {:#?}: {:#?}", file_path, err); + Text::new(&msg).danger().into() + }) + } + + fn view_files(&'a self) -> Vec> { + self.file_paths + .iter() + .map(|f| { + view_file( + f, + self.open_file_err + .clone() + .filter(|(file, _err)| *file == *f) + .map(|(_file, err)| err), + ) + }) + .collect() + } + + fn view_new_file_button(&'a self) -> iced::Element<'a, Message> { + panel_button::(Text::default("New file").into(), Message::NewFile).into() + } + + fn view_open_file_button(&'a self) -> iced::Element<'a, Message> { + panel_button::(Text::default("Open file").into(), Message::PickFile).into() + } + + fn view_import_from_actualbudget_button(&'a self) -> iced::Element<'a, Message> { + panel_button::( + Text::default("Import from Actualbudget").into(), + Message::ImportFromActualbudget, + ) + .into() + } +} + +fn view_file<'a>(file_path: &'a PathBuf, err: Option) -> iced::Element<'a, Message> { + let mut cols: Vec> = err + .map(|err| { + let msg = format!("Failed to open new file {:#?}: {:#?}", file_path, err); + vec![Text::new(&msg).danger().small().into()] + }) + .unwrap_or_else(Vec::new); + + cols.push( + Text::default(file_path.to_str().unwrap_or("")) + .small() + .weak() + .into(), + ); + + cols.push( + Text::default( + file_path + .file_name() + .map(|os_str| os_str.to_str().unwrap_or("[invalid Unicode]")) + .unwrap_or("[invalid filename]"), + ) + .into(), + ); + + panel_button(column(cols), Message::OpenFile(file_path.clone())).into() +} diff --git a/schist_desktop_gui/src/gui/screens/main_screen/main_screen.rs b/schist_desktop_gui/src/gui/screens/main_screen/main_screen.rs index 0af3c28..00f4ec3 100644 --- a/schist_desktop_gui/src/gui/screens/main_screen/main_screen.rs +++ b/schist_desktop_gui/src/gui/screens/main_screen/main_screen.rs @@ -147,12 +147,12 @@ impl<'a> Component<'a, Message, Action> for MainScreen { } impl MainScreen { - pub fn new(buckets: Vec, accounts: Vec) -> Self { + pub fn new() -> Self { Self { - balances_view: BalancesView::new(buckets.clone()), - buckets: buckets.clone(), - buckets_view: BucketsView::new(buckets, "Hello, buckets!"), - transactions_view: TransactionsView::new(accounts, "Hello, transactions!"), + balances_view: BalancesView::new(Vec::new()), + buckets: Vec::new(), + buckets_view: BucketsView::new(Vec::new(), "Hello, buckets!"), + transactions_view: TransactionsView::new(Vec::new(), "Hello, transactions!"), active_view: View::Balances, view_navigation: Navigation::new(Some(View::Balances), View::views()), } diff --git a/schist_desktop_gui/src/gui/screens/main_screen/view.rs b/schist_desktop_gui/src/gui/screens/main_screen/view.rs index eb9c88e..2be427b 100644 --- a/schist_desktop_gui/src/gui/screens/main_screen/view.rs +++ b/schist_desktop_gui/src/gui/screens/main_screen/view.rs @@ -1,4 +1,4 @@ -use iced::Element; +use iced::{widget::Text, Element}; use crate::gui::components::navigation; @@ -43,6 +43,6 @@ impl View { impl<'a> Into>> for View { fn into(self) -> Element<'a, navigation::Message> { - iced::widget::text(self.name()).into() + Text::new(self.name()).into() } } diff --git a/schist_desktop_gui/src/main.rs b/schist_desktop_gui/src/main.rs index 24aedc0..6dd2622 100644 --- a/schist_desktop_gui/src/main.rs +++ b/schist_desktop_gui/src/main.rs @@ -1,6 +1,8 @@ mod config; -mod connection; +mod database_connection; +mod dialog; mod gui; +mod paths; mod settings; mod shortcut; mod style; @@ -8,31 +10,22 @@ mod theme; mod traits; mod window_settings; +pub use database_connection::DatabaseConnection; + use anyhow::Context; -use diesel_migrations::MigrationHarness; -use schist_schema::migrations::MIGRATIONS; use crate::{ - config::Config, - connection::establish_connection, - gui::Gui, - settings::make_settings, - theme::make_theme, + config::Config, gui::Gui, settings::make_settings, theme::make_theme, window_settings::make_window_settings, }; fn main() -> anyhow::Result<()> { let config = Config::default(); - let mut connection = establish_connection() - .context("Failed to establish database connection")?; - connection.run_pending_migrations(MIGRATIONS) - .expect("Failed to run pending database migrations"); - -iced::application("Schist", Gui::update, Gui::view) - .settings(make_settings(&config.clone())) - .subscription(gui::subscription) - .theme(make_theme) - .window(make_window_settings(&config.clone())) - .run_with(move || Gui::new(&mut connection)) - .context("Failed to run Schist GUI") + iced::application("Schist", Gui::update, Gui::view) + .settings(make_settings(&config.clone())) + .subscription(gui::subscription) + .theme(make_theme) + .window(make_window_settings(&config.clone())) + .run_with(Gui::new) + .context("Failed to run Schist GUI") } diff --git a/schist_desktop_gui/src/paths.rs b/schist_desktop_gui/src/paths.rs new file mode 100644 index 0000000..f5cbf33 --- /dev/null +++ b/schist_desktop_gui/src/paths.rs @@ -0,0 +1,56 @@ +use std::{ + fs::{create_dir_all, read_dir, FileType}, + path::{Path, PathBuf}, +}; + +use anyhow::Context; + +const SCHIST_DATA_DIR: &'static str = "schist"; + +pub fn get_file_paths() -> Vec { + get_file_paths_checked() + .inspect(|paths| println!("Found file paths: {:?}", paths)) + .inspect_err(|err| println!("Couldn't find any file paths: {:?}", err)) + .unwrap_or_else(|_| Vec::new()) +} + +fn get_file_paths_checked() -> anyhow::Result> { + let schist_data_dir = get_and_make_schist_data_dir()?; + Ok(read_dir(schist_data_dir)? + .filter_map(|entry| match entry { + Ok(entry) => { + if is_sqlite_file(&entry) { + Some(entry.path().to_path_buf()) + } else { + None + } + } + Err(_) => None, + }) + .collect()) +} + +fn is_sqlite_file(entry: &std::fs::DirEntry) -> bool { + entry + .file_type() + .is_ok_and(|ft| (FileType::is_file(&ft) || FileType::is_symlink(&ft))) + && entry + .path() + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("sqlite")) +} + +pub fn get_schist_database_directory() -> anyhow::Result { + get_and_make_schist_data_dir() +} + +fn get_and_make_schist_data_dir() -> anyhow::Result { + let data_dir = dirs::data_dir().context("Failed to get app data directory")?; + let schist_data_dir = data_dir.join(Path::new(SCHIST_DATA_DIR)); + create_dir_all(&schist_data_dir).context(format!( + "Failed to create app data directory at {}", + schist_data_dir.display() + ))?; + println!("Found app data directory at {:?}", schist_data_dir); + Ok(schist_data_dir) +} diff --git a/schist_desktop_gui/src/style.rs b/schist_desktop_gui/src/style.rs index 01ec2ad..c2ecb02 100644 --- a/schist_desktop_gui/src/style.rs +++ b/schist_desktop_gui/src/style.rs @@ -1 +1,5 @@ +pub const SPACING_MD: u16 = 24; pub const SPACING_LG: u16 = 32; + +pub const TEXT_SIZE_SM: u16 = 12; +pub const TEXT_SIZE_BASE: u16 = 16; diff --git a/schist_desktop_gui/src/theme.rs b/schist_desktop_gui/src/theme.rs index 61d736b..98df36f 100644 --- a/schist_desktop_gui/src/theme.rs +++ b/schist_desktop_gui/src/theme.rs @@ -1,7 +1,71 @@ -use iced::Theme; +use iced::{ + theme::{palette, Palette}, + Color, Theme, +}; use crate::gui::Gui; pub fn make_theme(_state: &Gui) -> Theme { - Theme::GruvboxDark + Theme::custom_with_fn( + String::from("schist"), + Palette { + background: black(), + text: white(), + primary: accent_8(), + success: accent_8(), + danger: red_4(), + }, + |palette| palette::Extended { + background: palette::Background { + base: palette::Pair::new(black(), palette.text), + weak: palette::Pair::new(grey_2(), palette.text), + strong: palette::Pair::new(grey_2(), palette.text), + }, + primary: palette::Primary { + base: palette::Pair::new(palette.background, palette.text), + weak: palette::Pair::new(palette.background, palette.text), + strong: palette::Pair::new(palette.background, palette.text), + }, + secondary: palette::Secondary { + base: palette::Pair::new(palette.background, palette.text), + weak: palette::Pair::new(palette.background, palette.text), + strong: palette::Pair::new(palette.background, palette.text), + }, + success: palette::Success { + base: palette::Pair::new(palette.background, accent_8()), + weak: palette::Pair::new(palette.background, accent_8()), + strong: palette::Pair::new(palette.background, accent_8()), + }, + danger: palette::Danger { + base: palette::Pair::new(palette.background, red_7()), + weak: palette::Pair::new(palette.background, red_7()), + strong: palette::Pair::new(palette.background, red_4()), + }, + is_dark: true, + }, + ) +} + +fn black() -> Color { + Color::from_rgb8(0, 6, 19) +} + +fn grey_2() -> Color { + Color::from_rgb8(11, 31, 47) +} + +fn white() -> Color { + Color::from_rgb8(225, 249, 255) +} + +fn accent_8() -> Color { + Color::from_rgb8(129, 199, 255) +} + +fn red_4() -> Color { + Color::from_rgb8(146, 0, 0) +} + +fn red_7() -> Color { + Color::from_rgb8(251, 115, 98) } diff --git a/transformers/actualbudget_transformer/actualbudget_fakes/src/actualbudget_category.rs b/transformers/actualbudget_transformer/actualbudget_fakes/src/actualbudget_category.rs index 84fb32a..a02e672 100644 --- a/transformers/actualbudget_transformer/actualbudget_fakes/src/actualbudget_category.rs +++ b/transformers/actualbudget_transformer/actualbudget_fakes/src/actualbudget_category.rs @@ -8,6 +8,7 @@ pub fn make_fake_actualbudget_category_builder(id: &str) -> ActualbudgetCategory let mut builder = ActualbudgetCategoryBuilder::default(); builder .id(String::from(id)) + .group_id(None) .name(String::new()); return builder } diff --git a/transformers/actualbudget_transformer/actualbudget_fakes/src/actualbudget_category_group.rs b/transformers/actualbudget_transformer/actualbudget_fakes/src/actualbudget_category_group.rs new file mode 100644 index 0000000..8695e04 --- /dev/null +++ b/transformers/actualbudget_transformer/actualbudget_fakes/src/actualbudget_category_group.rs @@ -0,0 +1,13 @@ +use actualbudget_models::{ActualbudgetCategoryGroup, ActualbudgetCategoryGroupBuilder}; + +pub fn make_fake_actualbudget_category_group(id: &str) -> ActualbudgetCategoryGroup { + make_fake_actualbudget_category_group_builder(id).build().unwrap() +} + +pub fn make_fake_actualbudget_category_group_builder(id: &str) -> ActualbudgetCategoryGroupBuilder { + let mut builder = ActualbudgetCategoryGroupBuilder::default(); + builder + .id(String::from(id)) + .name(None); + return builder +} diff --git a/transformers/actualbudget_transformer/actualbudget_fakes/src/actualbudget_transaction.rs b/transformers/actualbudget_transformer/actualbudget_fakes/src/actualbudget_transaction.rs index 548424d..f897444 100644 --- a/transformers/actualbudget_transformer/actualbudget_fakes/src/actualbudget_transaction.rs +++ b/transformers/actualbudget_transformer/actualbudget_fakes/src/actualbudget_transaction.rs @@ -17,6 +17,7 @@ pub fn make_fake_actualbudget_transaction_builder(id: &str) -> ActualbudgetTrans .payee(None) .notes(None) .date(ActualbudgetDate::from_i32(19700101)) - .transfer_id(None); + .transfer_id(None) + .starting_balance_flag(0); return builder } diff --git a/transformers/actualbudget_transformer/actualbudget_fakes/src/lib.rs b/transformers/actualbudget_transformer/actualbudget_fakes/src/lib.rs index 815c962..dbd0849 100644 --- a/transformers/actualbudget_transformer/actualbudget_fakes/src/lib.rs +++ b/transformers/actualbudget_transformer/actualbudget_fakes/src/lib.rs @@ -1,5 +1,6 @@ pub mod actualbudget_account; pub mod actualbudget_category; +pub mod actualbudget_category_group; pub mod actualbudget_payee; pub mod actualbudget_transaction; pub mod actualbudget_zero_budget; diff --git a/transformers/actualbudget_transformer/actualbudget_models/src/actualbudget_category.rs b/transformers/actualbudget_transformer/actualbudget_models/src/actualbudget_category.rs index bf35450..84ad4b0 100644 --- a/transformers/actualbudget_transformer/actualbudget_models/src/actualbudget_category.rs +++ b/transformers/actualbudget_transformer/actualbudget_models/src/actualbudget_category.rs @@ -5,5 +5,6 @@ use diesel::{prelude::Queryable, Selectable}; #[diesel(table_name = crate::actualbudget_schema::categories)] pub struct ActualbudgetCategory { pub id: String, + pub group_id: Option, pub name: String, } diff --git a/transformers/actualbudget_transformer/actualbudget_models/src/actualbudget_category_group.rs b/transformers/actualbudget_transformer/actualbudget_models/src/actualbudget_category_group.rs new file mode 100644 index 0000000..8ec21f4 --- /dev/null +++ b/transformers/actualbudget_transformer/actualbudget_models/src/actualbudget_category_group.rs @@ -0,0 +1,9 @@ +use derive_builder::Builder; +use diesel::prelude::*; + +#[derive(Builder, Clone, Queryable, Selectable)] +#[diesel(table_name = crate::actualbudget_schema::category_groups)] +pub struct ActualbudgetCategoryGroup { + pub id: String, + pub name: Option, +} diff --git a/transformers/actualbudget_transformer/actualbudget_models/src/actualbudget_schema.rs b/transformers/actualbudget_transformer/actualbudget_models/src/actualbudget_schema.rs index 4e1ba24..3a26344 100644 --- a/transformers/actualbudget_transformer/actualbudget_models/src/actualbudget_schema.rs +++ b/transformers/actualbudget_transformer/actualbudget_models/src/actualbudget_schema.rs @@ -8,10 +8,20 @@ diesel::table! { diesel::table! { categories (id) { id -> Text, + #[sql_name = "cat_group"] + group_id -> Nullable, name -> Text, } } +diesel::table! { + category_groups (id) { + id -> Text, + name -> Nullable, + } +} + + diesel::table! { v_transactions (id) { id -> Text, @@ -27,10 +37,10 @@ diesel::table! { notes -> Nullable, date -> Integer, transfer_id -> Nullable, + starting_balance_flag -> Integer, // imported_id -> Text, // error -> Text, // imported_payee -> Text, - // starting_balance_flag -> Integer, // sort_order -> Real, // cleared -> Integer, // reconciled -> Integer, diff --git a/transformers/actualbudget_transformer/actualbudget_models/src/actualbudget_transaction.rs b/transformers/actualbudget_transformer/actualbudget_models/src/actualbudget_transaction.rs index c8ac522..d6270da 100644 --- a/transformers/actualbudget_transformer/actualbudget_models/src/actualbudget_transaction.rs +++ b/transformers/actualbudget_transformer/actualbudget_models/src/actualbudget_transaction.rs @@ -3,7 +3,9 @@ use diesel::prelude::{Identifiable, Queryable, QueryableByName, Selectable}; use crate::actualbudget_date::ActualbudgetDate; -#[derive(Builder, Debug, PartialEq, Eq, Identifiable, QueryableByName, Queryable, Selectable, Clone)] +#[derive( + Builder, Debug, PartialEq, Eq, Identifiable, QueryableByName, Queryable, Selectable, Clone, +)] #[diesel(table_name = crate::actualbudget_schema::v_transactions)] pub struct ActualbudgetTransaction { pub id: String, @@ -17,4 +19,11 @@ pub struct ActualbudgetTransaction { pub notes: Option, pub date: ActualbudgetDate, pub transfer_id: Option, + pub starting_balance_flag: i32, +} + +impl ActualbudgetTransaction { + pub fn is_starting_balance(&self) -> bool { + self.starting_balance_flag != 0 + } } diff --git a/transformers/actualbudget_transformer/actualbudget_models/src/lib.rs b/transformers/actualbudget_transformer/actualbudget_models/src/lib.rs index 2d7d46c..b46b287 100644 --- a/transformers/actualbudget_transformer/actualbudget_models/src/lib.rs +++ b/transformers/actualbudget_transformer/actualbudget_models/src/lib.rs @@ -1,5 +1,6 @@ mod actualbudget_account; mod actualbudget_category; +mod actualbudget_category_group; mod actualbudget_date; mod actualbudget_payee; mod actualbudget_transaction; @@ -11,6 +12,8 @@ pub use actualbudget_account::ActualbudgetAccount; pub use actualbudget_account::ActualbudgetAccountBuilder; pub use actualbudget_category::ActualbudgetCategory; pub use actualbudget_category::ActualbudgetCategoryBuilder; +pub use actualbudget_category_group::ActualbudgetCategoryGroup; +pub use actualbudget_category_group::ActualbudgetCategoryGroupBuilder; pub use actualbudget_date::ActualbudgetDate; pub use actualbudget_payee::ActualbudgetPayee; pub use actualbudget_payee::ActualbudgetPayeeBuilder; diff --git a/transformers/actualbudget_transformer/actualbudget_queries/src/actualbudget_category_groups.rs b/transformers/actualbudget_transformer/actualbudget_queries/src/actualbudget_category_groups.rs new file mode 100644 index 0000000..8d90765 --- /dev/null +++ b/transformers/actualbudget_transformer/actualbudget_queries/src/actualbudget_category_groups.rs @@ -0,0 +1,11 @@ +use actualbudget_models::ActualbudgetCategoryGroup; +use actualbudget_schema::actualbudget_schema::category_groups::dsl::category_groups; +use diesel::{result::Error, QueryDsl, RunQueryDsl, SelectableHelper, SqliteConnection}; + +pub fn get_all_actualbudget_category_groups( + connection: &mut SqliteConnection +) -> Result, Error> { + category_groups + .select(ActualbudgetCategoryGroup::as_select()) + .load(connection) +} diff --git a/transformers/actualbudget_transformer/actualbudget_queries/src/lib.rs b/transformers/actualbudget_transformer/actualbudget_queries/src/lib.rs index ca3f6ea..0fffcbb 100644 --- a/transformers/actualbudget_transformer/actualbudget_queries/src/lib.rs +++ b/transformers/actualbudget_transformer/actualbudget_queries/src/lib.rs @@ -1,5 +1,6 @@ pub mod actualbudget_accounts; pub mod actualbudget_categories; +pub mod actualbudget_category_groups; pub mod actualbudget_payees; pub mod actualbudget_transactions; pub mod actualbudget_zero_budgets; diff --git a/transformers/actualbudget_transformer/actualbudget_queries/tests/resources/actualbudget_test_db.sqlite b/transformers/actualbudget_transformer/actualbudget_queries/tests/resources/actualbudget_test_db.sqlite index 4146bb0..5f89924 100644 Binary files a/transformers/actualbudget_transformer/actualbudget_queries/tests/resources/actualbudget_test_db.sqlite and b/transformers/actualbudget_transformer/actualbudget_queries/tests/resources/actualbudget_test_db.sqlite differ diff --git a/transformers/actualbudget_transformer/actualbudget_queries/tests/resources/actualbudget_test_db_metadata.json b/transformers/actualbudget_transformer/actualbudget_queries/tests/resources/actualbudget_test_db_metadata.json index 006b23d..c88313a 100644 --- a/transformers/actualbudget_transformer/actualbudget_queries/tests/resources/actualbudget_test_db_metadata.json +++ b/transformers/actualbudget_transformer/actualbudget_queries/tests/resources/actualbudget_test_db_metadata.json @@ -1,7 +1,7 @@ { - "id": "My-Finances-1-36ec11a", - "budgetName": "My Finances 1", - "userId": "fec9ba17-c260-4f55-8d44-676ef0ec5da9", - "lastScheduleRun": "2024-11-21", - "resetClock": true + "id": "My-Finances-1-36ec11a", + "budgetName": "My Finances 1", + "userId": "fec9ba17-c260-4f55-8d44-676ef0ec5da9", + "lastScheduleRun": "2025-08-16", + "resetClock": true } diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/Cargo.toml b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/Cargo.toml index 51febe1..202d041 100644 --- a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/Cargo.toml +++ b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/Cargo.toml @@ -12,6 +12,7 @@ diesel = { workspace = true, features = ["sqlite"] } diesel_migrations = { workspace = true } itertools = { workspace = true } libsqlite3-sys = { workspace = true, features = ["bundled"] } +schist_fakes = { workspace = true } schist_models = { workspace = true } schist_queries = { workspace = true } schist_traits = { workspace = true } diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/actualbudget_state.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/actualbudget_state.rs index 5379b62..cda63b1 100644 --- a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/actualbudget_state.rs +++ b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/actualbudget_state.rs @@ -1,7 +1,13 @@ use std::cmp::min; -use actualbudget_models::{ActualbudgetAccount, ActualbudgetCategory, ActualbudgetDate, ActualbudgetPayee, ActualbudgetTransaction, ActualbudgetZeroBudget}; -use actualbudget_queries::{actualbudget_accounts::{get_all_actualbudget_accounts, get_category_transfers_account}, actualbudget_categories::get_all_actualbudget_categories, actualbudget_payees::get_all_actualbudget_payees, actualbudget_transactions::{get_all_actualbudget_transactions, get_first_actualbudget_transaction_date}, actualbudget_zero_budgets::{get_all_actualbudget_zero_budgets, get_first_zero_budget_date}}; +use actualbudget_models::{ + ActualbudgetAccount, ActualbudgetCategory, ActualbudgetCategoryGroup, ActualbudgetDate, ActualbudgetPayee, ActualbudgetTransaction, ActualbudgetZeroBudget +}; +use actualbudget_queries::{ + actualbudget_accounts::get_all_actualbudget_accounts, actualbudget_categories::get_all_actualbudget_categories, actualbudget_category_groups::get_all_actualbudget_category_groups, actualbudget_payees::get_all_actualbudget_payees, actualbudget_transactions::{ + get_all_actualbudget_transactions, get_first_actualbudget_transaction_date, + }, actualbudget_zero_budgets::{get_all_actualbudget_zero_budgets, get_first_zero_budget_date} +}; use anyhow::Result; use diesel::SqliteConnection; @@ -9,23 +15,25 @@ pub struct ActualbudgetState { pub actualbudget_first_activity_date: ActualbudgetDate, pub actualbudget_accounts: Vec, pub actualbudget_categories: Vec, + pub actualbudget_category_groups: Vec, pub actualbudget_payees: Vec, pub actualbudget_zero_budgets: Vec, - pub actualbudget_category_transfers_account: Option, pub actualbudget_transactions: Vec, } -pub fn read_actualbudget_state(actualbudget_connection: &mut SqliteConnection) -> Result { +pub fn read_actualbudget_state( + actualbudget_connection: &mut SqliteConnection, +) -> Result { Ok(ActualbudgetState { actualbudget_first_activity_date: min( - get_first_actualbudget_transaction_date(actualbudget_connection)?, - get_first_zero_budget_date(actualbudget_connection)?, - ), + get_first_actualbudget_transaction_date(actualbudget_connection)?, + get_first_zero_budget_date(actualbudget_connection)?, + ), actualbudget_accounts: get_all_actualbudget_accounts(actualbudget_connection)?, actualbudget_categories: get_all_actualbudget_categories(actualbudget_connection)?, + actualbudget_category_groups: get_all_actualbudget_category_groups(actualbudget_connection)?, actualbudget_payees: get_all_actualbudget_payees(actualbudget_connection)?, actualbudget_zero_budgets: get_all_actualbudget_zero_budgets(actualbudget_connection)?, - actualbudget_category_transfers_account: get_category_transfers_account(actualbudget_connection)?, actualbudget_transactions: get_all_actualbudget_transactions(actualbudget_connection)?, }) } diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state.rs new file mode 100644 index 0000000..a116649 --- /dev/null +++ b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state.rs @@ -0,0 +1,27 @@ +mod is_account_transfer; +mod is_bucket_transfer; +mod transform_account_transfers; +mod transform_accounts; +mod transform_categories_to_buckets; +mod transform_date; +mod transform_id; +mod transform_state; +mod transform_transactions; +mod transform_transactions_to_bucket_transfers; +mod transform_zero_budgets_to_drips; +mod transform_zero_budgets_to_pipes; + +use is_account_transfer::is_account_transfer; +use is_account_transfer::is_not_account_transfer; +use is_bucket_transfer::is_bucket_transfer; +use is_bucket_transfer::is_not_bucket_transfer; +use transform_account_transfers::transform_account_transfers; +use transform_accounts::transform_accounts; +use transform_categories_to_buckets::transform_categories_to_buckets; +use transform_date::transform_date; +use transform_id::transform_id; +use transform_transactions::transform_transactions; +use transform_transactions_to_bucket_transfers::transform_transactions_to_bucket_transfers; +use transform_zero_budgets_to_pipes::transform_zero_budgets_to_pipes; + +pub use transform_state::transform_state; diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/is_bucket_transfer.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/is_bucket_transfer.rs new file mode 100644 index 0000000..188697e --- /dev/null +++ b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/is_bucket_transfer.rs @@ -0,0 +1,46 @@ +use actualbudget_models::ActualbudgetTransaction; + +pub fn is_bucket_transfer( + transaction: &ActualbudgetTransaction, + transactions: &[ActualbudgetTransaction], +) -> bool { + is_parent_bucket_transfer(transaction, transactions) + || is_child_bucket_transfer(transaction, transactions) +} + +fn is_parent_bucket_transfer( + transaction: &ActualbudgetTransaction, + transactions: &[ActualbudgetTransaction], +) -> bool { + if !transaction.is_parent { + return false; + } + + let sum_of_children_amounts: i32 = transactions + .iter() + .filter(|t| is_parent_and_child(transaction, t)) + .map(|t| t.amount) + .sum(); + sum_of_children_amounts == 0 +} + +fn is_child_bucket_transfer( + transaction: &ActualbudgetTransaction, + transactions: &[ActualbudgetTransaction], +) -> bool { + transaction.is_child + && transactions + .iter() + .any(|t| is_parent_and_child(t, transaction)) +} + +fn is_parent_and_child(parent: &ActualbudgetTransaction, child: &ActualbudgetTransaction) -> bool { + parent.is_parent && child.is_child && child.parent_id.clone().is_some_and(|id| id == parent.id) +} + +pub fn is_not_bucket_transfer( + transaction: &ActualbudgetTransaction, + transactions: &[ActualbudgetTransaction], +) -> bool { + !is_bucket_transfer(transaction, transactions) +} diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/is_category_transfer.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/is_category_transfer.rs deleted file mode 100644 index c82387c..0000000 --- a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/is_category_transfer.rs +++ /dev/null @@ -1,18 +0,0 @@ -use actualbudget_models::{ActualbudgetAccount, ActualbudgetTransaction}; - -pub fn is_category_transfer( - transaction: &ActualbudgetTransaction, - actualbudget_category_transfers_account: &Option -) -> bool { - match actualbudget_category_transfers_account { - None => false, - Some(acc) => transaction.account_id.clone() == acc.id.clone(), - } -} - -pub fn is_not_category_transfer( - transaction: &ActualbudgetTransaction, - actualbudget_category_transfers_account: &Option -) -> bool { - !is_category_transfer(transaction, actualbudget_category_transfers_account) -} diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/mod.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/mod.rs deleted file mode 100644 index 5fadc2c..0000000 --- a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/mod.rs +++ /dev/null @@ -1,27 +0,0 @@ -mod is_account_transfer; -mod is_category_transfer; -mod transform_account_transfers; -mod transform_accounts; -mod transform_budget_drips; -mod transform_categories; -mod transform_category_transfers; -mod transform_date; -mod transform_id; -mod transform_state; -mod transform_transactions; -mod transform_zero_budgets; - -use is_account_transfer::is_account_transfer; -use is_account_transfer::is_not_account_transfer; -use is_category_transfer::is_category_transfer; -use is_category_transfer::is_not_category_transfer; -use transform_account_transfers::transform_account_transfers; -use transform_accounts::transform_accounts; -use transform_categories::transform_categories; -use transform_category_transfers::transform_category_transfers; -use transform_date::transform_date; -use transform_id::transform_id; -use transform_transactions::transform_transactions; -use transform_zero_budgets::transform_zero_budgets; - -pub use transform_state::transform_state; diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_account_transfers.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_account_transfers.rs index c41b812..a116086 100644 --- a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_account_transfers.rs +++ b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_account_transfers.rs @@ -112,7 +112,7 @@ fn transform_account_transfer( mod test { use actualbudget_fakes::actualbudget_transaction::make_fake_actualbudget_transaction_builder; use actualbudget_models::ActualbudgetDate; - use schist_models::DateUtc; + use schist_models::{AccountTransfer, DateUtc}; use crate::transform_state::transform_id::transform_id; @@ -147,14 +147,14 @@ mod test { assert_eq!(0, remaining_transactions.len()); assert_eq!(1, account_transfers.len()); let account_transfer = account_transfers.get(0).unwrap(); - assert_eq!( - "2024-08-01".parse::().unwrap(), - account_transfer.date - ); - assert_eq!("My notes", account_transfer.description); - assert_eq!(100, account_transfer.amount); - assert_eq!(transform_id("0"), account_transfer.from_account_id); - assert_eq!(transform_id("1"), account_transfer.to_account_id); + assert_eq!(AccountTransfer { + id: account_transfer.id, + amount: 100, + date: "2024-08-01".parse().unwrap(), + description: String::from("My notes"), + from_account_id: transform_id("0"), + to_account_id: transform_id("1"), + }, *account_transfer); } #[test] diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_accounts.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_accounts.rs index c900da7..76eaf0c 100644 --- a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_accounts.rs +++ b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_accounts.rs @@ -1,32 +1,194 @@ -use actualbudget_models::ActualbudgetAccount; +use actualbudget_models::{ActualbudgetAccount, ActualbudgetTransaction}; use schist_models::{Account, DateUtc}; -use super::transform_id; +use super::{is_bucket_transfer, transform_id}; pub fn transform_accounts( actualbudget_accounts: &[ActualbudgetAccount], - actualbudget_category_transfers_account: &Option, + actualbudget_transactions: &[ActualbudgetTransaction], first_activity_date: &DateUtc, ) -> Vec { actualbudget_accounts .iter() - .filter(|a| { - actualbudget_category_transfers_account - .clone() - .is_some_and(|cta| cta.id != a.id) - }) - .map(|a| transform_account(a, first_activity_date)) + .filter(|a| !is_empty_account(a, actualbudget_transactions)) + .map(|a| transform_account(a, actualbudget_transactions, first_activity_date)) .collect() } +fn is_empty_account( + actualbudget_account: &ActualbudgetAccount, + actualbudget_transactions: &[ActualbudgetTransaction], +) -> bool { + actualbudget_transactions.iter().all(|t| { + t.is_starting_balance() && t.amount == 0 + || t.account_id != actualbudget_account.id + || is_bucket_transfer(t, actualbudget_transactions) + }) +} + fn transform_account( actualbudget_account: &ActualbudgetAccount, + actualbudget_transactions: &[ActualbudgetTransaction], opening_date: &DateUtc, ) -> Account { + let opening_balance = actualbudget_transactions + .iter() + .filter(|t| t.is_starting_balance()) + .filter(|t| t.account_id == actualbudget_account.id) + .map(|t| t.amount) + .sum(); + Account { id: transform_id(&actualbudget_account.id.clone()), name: actualbudget_account.name.clone(), - opening_balance: 0, + opening_balance, opening_date: opening_date.clone(), } } + +#[cfg(test)] +mod test { + use actualbudget_fakes::{ + actualbudget_account::make_fake_actualbudget_account_builder, + actualbudget_transaction::make_fake_actualbudget_transaction_builder, + }; + use schist_models::{Account, DateUtc}; + + use super::{transform_accounts, transform_id}; + + #[test] + fn given_starting_balances_then_sets_opening_balances() { + let actualbudget_accounts = vec![ + make_fake_actualbudget_account_builder("0") + .name(String::from("Cash account")) + .build() + .unwrap(), + make_fake_actualbudget_account_builder("1") + .name(String::from("Current account")) + .build() + .unwrap(), + ]; + + let actualbudget_transactions = vec![ + make_fake_actualbudget_transaction_builder("0") + .account_id(String::from("0")) + .amount(100) + .starting_balance_flag(1) + .build() + .unwrap(), + make_fake_actualbudget_transaction_builder("1") + .account_id(String::from("1")) + .amount(200) + .starting_balance_flag(1) + .build() + .unwrap(), + make_fake_actualbudget_transaction_builder("2") + .account_id(String::from("1")) + .amount(50) + .starting_balance_flag(1) + .build() + .unwrap(), + make_fake_actualbudget_transaction_builder("3") + .account_id(String::from("0")) + .amount(300) + .starting_balance_flag(0) + .build() + .unwrap(), + make_fake_actualbudget_transaction_builder("4") + .account_id(String::from("1")) + .amount(600) + .starting_balance_flag(0) + .build() + .unwrap(), + ]; + let first_activity_date: DateUtc = "2024-08-01".parse().unwrap(); + + let result = transform_accounts( + &actualbudget_accounts, + &actualbudget_transactions, + &first_activity_date, + ); + + assert_eq!(2, result.len()); + assert_eq!( + Some(Account { + id: transform_id("0"), + name: String::from("Cash account"), + opening_balance: 100, + opening_date: "2024-08-01".parse().unwrap(), + }), + result.iter().find(|a| a.name.eq("Cash account")).cloned() + ); + assert_eq!( + Some(Account { + id: transform_id("1"), + name: String::from("Current account"), + opening_balance: 250, + opening_date: "2024-08-01".parse().unwrap(), + }), + result + .iter() + .find(|a| a.name.eq("Current account")) + .cloned() + ); + } + + #[test] + fn filters_out_empty_account() { + let actualbudget_accounts = vec![ + make_fake_actualbudget_account_builder("0") + .name(String::from("Cash account")) + .build() + .unwrap(), + make_fake_actualbudget_account_builder("1") + .name(String::from("Current account")) + .build() + .unwrap(), + ]; + + let actualbudget_transactions = vec![ + make_fake_actualbudget_transaction_builder("0") + .account_id(String::from("0")) + .amount(0) + .starting_balance_flag(1) + .build() + .unwrap(), + make_fake_actualbudget_transaction_builder("1") + .account_id(String::from("1")) + .amount(200) + .starting_balance_flag(1) + .build() + .unwrap(), + make_fake_actualbudget_transaction_builder("2") + .account_id(String::from("1")) + .amount(50) + .starting_balance_flag(1) + .build() + .unwrap(), + make_fake_actualbudget_transaction_builder("3") + .account_id(String::from("1")) + .amount(300) + .starting_balance_flag(0) + .build() + .unwrap(), + ]; + let first_activity_date: DateUtc = "2024-08-01".parse().unwrap(); + + let result = transform_accounts( + &actualbudget_accounts, + &actualbudget_transactions, + &first_activity_date, + ); + + assert_eq!(1, result.len()); + assert_eq!( + Some(Account { + id: transform_id("1"), + name: String::from("Current account"), + opening_balance: 250, + opening_date: "2024-08-01".parse().unwrap(), + }), + result.first().cloned(), + ); + } +} diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_budget_drips.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_budget_drips.rs deleted file mode 100644 index 76509cd..0000000 --- a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_budget_drips.rs +++ /dev/null @@ -1,325 +0,0 @@ -use std::hash::{DefaultHasher, Hash, Hasher}; - -use actualbudget_models::ActualbudgetZeroBudget; -use anyhow::{Context, Result}; -use itertools::Itertools; -use schist_models::{DateUtc, Drip}; -use schist_traits::Dateable; - -use super::{transform_date::transform_date, transform_id::transform_id}; - -pub fn transform_budget_drips( - actualbudget_zero_budgets: &[ActualbudgetZeroBudget], -) -> Result> { - let non_zero_actualbudget_zero_budgets: Vec = actualbudget_zero_budgets - .iter() - .filter(|zb| zb.amount != 0) - .cloned() - .collect(); - - let budget_drips = - group_actualbudget_zero_budgets_by_category_id(&non_zero_actualbudget_zero_budgets) - .iter() - .map(sort_actualbudget_zero_budgets_by_month) - .flat_map(|sorted_actualbudget_zero_budgets| { - sorted_actualbudget_zero_budgets - .iter() - .map(map_actualbudget_zero_budget_to_budget_drips) - .collect::>>>() - }) - .flatten() - .flatten() - .filter(|bd| bd.amount != 0) - .collect(); - - Ok(budget_drips) -} - -fn map_actualbudget_zero_budget_to_budget_drips( - actualbudget_zero_budget: &ActualbudgetZeroBudget, -) -> Result> { - let context = || "failed to map actualbudget zero budgets to budget drips"; - - let first_day_of_month: DateUtc = Into::::into( - transform_date(&actualbudget_zero_budget.month).with_context(context)?, - ); - let month = first_day_of_month.month(); - let days_in_month: Vec = (0..31) - .map(|i| first_day_of_month.add_days(i)) - .take_while(|d| match d { - Err(_) => true, - Ok(d) => d.month() == month, - }) - .filter(Result::is_ok) - .map(Result::unwrap) - .collect(); - - // unwrap() can panic, but days_in_month is guaranteed to have between 0 and - // 31 elements, so we should be grand - let num_days_in_month: i32 = days_in_month.len().try_into().unwrap(); - - let amount = actualbudget_zero_budget.amount / num_days_in_month; - let remainder_amount = actualbudget_zero_budget.amount - (amount * num_days_in_month); - - let budget_drips: Vec = days_in_month - .iter() - .map(|&date| Drip { - id: transform_budget_drip_id(&actualbudget_zero_budget, &date), - bucket_id: transform_id(&actualbudget_zero_budget.category_id), - date, - amount: if date == first_day_of_month { - amount + remainder_amount - } else { - amount - }, - inserted_at_unix_seconds: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("The current time should be after the Unix epoch") - .as_secs() - .try_into() - .expect("The current time's timestamp in seconds should fit into a 32-bit signed integer"), - pipe_id: 0, - }) - .collect(); - - Ok(budget_drips) -} - -fn sort_actualbudget_zero_budgets_by_month( - actualbudget_zero_budgets_by_category_id: &Vec, -) -> Vec { - let mut actualbudget_zero_budgets = actualbudget_zero_budgets_by_category_id.clone(); - actualbudget_zero_budgets.sort_by_key(|zb| zb.month.clone()); - actualbudget_zero_budgets -} - -fn group_actualbudget_zero_budgets_by_category_id( - actualbudget_zero_budgets: &[ActualbudgetZeroBudget], -) -> Vec> { - let category_ids = actualbudget_zero_budgets - .iter() - .map(|zb| zb.category_id.clone()) - .unique(); - - category_ids - .map(|category_id| { - actualbudget_zero_budgets - .iter() - .filter(|zb| zb.category_id == category_id) - .map(ActualbudgetZeroBudget::clone) - .collect() - }) - .collect() -} - -fn transform_budget_drip_id( - actualbudget_zero_budget: &ActualbudgetZeroBudget, - date: &DateUtc, -) -> i32 { - let mut hasher = DefaultHasher::new(); - let unique_str = actualbudget_zero_budget.id.clone() + date.to_string().as_str(); - unique_str.hash(&mut hasher); - (hasher.finish() % std::convert::TryInto::::try_into(i32::MAX).unwrap()) - .try_into() - .unwrap() -} - -#[cfg(test)] -mod test { - use actualbudget_fakes::actualbudget_zero_budget::make_fake_actualbudget_zero_budget_builder; - use actualbudget_models::ActualbudgetDate; - use schist_models::Drip; - use schist_traits::Dateable; - - use crate::transform_state::transform_id::transform_id; - - use super::transform_budget_drips; - - mod map_actualbudget_zero_budget_to_budget_drips { - use actualbudget_fakes::actualbudget_zero_budget::make_fake_actualbudget_zero_budget_builder; - use actualbudget_models::ActualbudgetDate; - use schist_models::Drip; - use schist_traits::Dateable; - - use crate::transform_state::transform_budget_drips::map_actualbudget_zero_budget_to_budget_drips; - - #[test] - fn when_zero_budget_with_integer_budget_per_day_then_evenly_divided_budget_drips() { - let actualbudget_zero_budget = make_fake_actualbudget_zero_budget_builder("0") - .amount(31 * 100) - .category_id(String::from("0")) - .month(ActualbudgetDate::from_i32(202401)) - .build() - .unwrap(); - - let result = map_actualbudget_zero_budget_to_budget_drips(&actualbudget_zero_budget); - - assert!(result.is_ok()); - let mut budget_drips = result.unwrap(); - assert_eq!(31, budget_drips.len()); - assert!(budget_drips.iter().all(|bd| bd.amount == 100)); - budget_drips.sort_by_key(|bd| bd.date); - for i in 1..31 { - assert_eq!( - Ok(i), - budget_drips.get(i - 1).unwrap().date.day().try_into() - ); - } - } - - #[test] - fn when_zero_budget_with_non_integer_budget_per_day_then_remainder_on_one_day() { - let actualbudget_zero_budget = make_fake_actualbudget_zero_budget_builder("0") - .amount(31 * 100 + 30) - .category_id(String::from("0")) - .month(ActualbudgetDate::from_i32(202401)) - .build() - .unwrap(); - - let result = map_actualbudget_zero_budget_to_budget_drips(&actualbudget_zero_budget); - - assert!(result.is_ok()); - let budget_drips = result.unwrap(); - assert_eq!(31, budget_drips.len()); - - let budget_drips_amount_not_floored: Vec<&Drip> = - budget_drips.iter().filter(|bd| bd.amount != 100).collect(); - assert_eq!(1, budget_drips_amount_not_floored.len()); - - let budget_drips_floored: Vec<&Drip> = - budget_drips.iter().filter(|bd| bd.amount == 100).collect(); - assert_eq!(30, budget_drips_floored.len()); - } - } - - #[test] - fn when_no_zero_budgets_then_no_budget_drips() { - let actualbudget_zero_budgets = []; - let result = transform_budget_drips(&actualbudget_zero_budgets); - assert!(result.is_ok()); - assert_eq!(result.unwrap().len(), 0); - } - - #[test] - fn when_one_zero_budget_with_integer_budget_per_day_then_evenly_divided_budget_drips() { - let actualbudget_zero_budgets = [make_fake_actualbudget_zero_budget_builder("0") - .amount(31 * 100) - .category_id(String::from("0")) - .month(ActualbudgetDate::from_i32(202401)) - .build() - .unwrap()]; - - let result = transform_budget_drips(&actualbudget_zero_budgets); - - assert!(result.is_ok()); - let mut budget_drips = result.unwrap(); - assert_eq!(31, budget_drips.len()); - assert!(budget_drips.iter().all(|bd| bd.amount == 100)); - budget_drips.sort_by_key(|bd| bd.date); - for i in 1..31 { - assert_eq!( - Ok(i), - budget_drips.get(i - 1).unwrap().date.day().try_into() - ); - } - } - - #[test] - fn when_one_zero_budgets_with_non_integer_budget_per_day_then_remainder_on_one_day() { - let actualbudget_zero_budgets = [make_fake_actualbudget_zero_budget_builder("0") - .amount(31 * 100 + 30) - .category_id(String::from("0")) - .month(ActualbudgetDate::from_i32(202401)) - .build() - .unwrap()]; - - let result = transform_budget_drips(&actualbudget_zero_budgets); - - assert!(result.is_ok()); - let budget_drips = result.unwrap(); - assert_eq!(31, budget_drips.len()); - - let budget_drips_amount_not_floored: Vec<&Drip> = - budget_drips.iter().filter(|bd| bd.amount != 100).collect(); - assert_eq!(1, budget_drips_amount_not_floored.len()); - - let budget_drips_floored: Vec<&Drip> = - budget_drips.iter().filter(|bd| bd.amount == 100).collect(); - assert_eq!(30, budget_drips_floored.len()); - } - - #[test] - fn when_multiple_zero_budgets_with_integer_budget_per_day_across_multiple_categories_then_evenly_divided_budget_drips( - ) { - let actualbudget_zero_budgets = [ - make_fake_actualbudget_zero_budget_builder("0") - .amount(31 * 100) - .category_id(String::from("0")) - .month(ActualbudgetDate::from_i32(202401)) - .build() - .unwrap(), - make_fake_actualbudget_zero_budget_builder("1") - .amount(31 * 200) - .category_id(String::from("1")) - .month(ActualbudgetDate::from_i32(202401)) - .build() - .unwrap(), - make_fake_actualbudget_zero_budget_builder("2") - .amount(29 * 110) - .category_id(String::from("0")) - .month(ActualbudgetDate::from_i32(202402)) - .build() - .unwrap(), - make_fake_actualbudget_zero_budget_builder("3") - .amount(29 * 210) - .category_id(String::from("1")) - .month(ActualbudgetDate::from_i32(202402)) - .build() - .unwrap(), - ]; - - let result = transform_budget_drips(&actualbudget_zero_budgets); - let category_0_id = transform_id("0"); - let category_1_id = transform_id("1"); - - assert!(result.is_ok()); - let budget_drips = result.unwrap(); - assert_eq!(31 + 31 + 29 + 29, budget_drips.len()); - - let budget_drips_jan_category_0: Vec<&Drip> = budget_drips - .iter() - .filter(|bd| bd.bucket_id == category_0_id && bd.date.month() == 1) - .collect(); - assert_eq!(31, budget_drips_jan_category_0.len()); - assert!(budget_drips_jan_category_0 - .iter() - .all(|bd| bd.amount == 100)); - - let budget_drips_jan_category_1: Vec<&Drip> = budget_drips - .iter() - .filter(|bd| bd.bucket_id == category_1_id && bd.date.month() == 1) - .collect(); - assert_eq!(31, budget_drips_jan_category_1.len()); - assert!(budget_drips_jan_category_1 - .iter() - .all(|bd| bd.amount == 200)); - - let budget_drips_feb_category_0: Vec<&Drip> = budget_drips - .iter() - .filter(|bd| bd.bucket_id == category_0_id && bd.date.month() == 2) - .collect(); - assert_eq!(29, budget_drips_feb_category_0.len()); - assert!(budget_drips_feb_category_0 - .iter() - .all(|bd| bd.amount == 110)); - - let budget_drips_feb_category_1: Vec<&Drip> = budget_drips - .iter() - .filter(|bd| bd.bucket_id == category_1_id && bd.date.month() == 2) - .collect(); - assert_eq!(29, budget_drips_feb_category_1.len()); - assert!(budget_drips_feb_category_1 - .iter() - .all(|bd| bd.amount == 210)); - } -} diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_categories.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_categories.rs deleted file mode 100644 index 5bea17c..0000000 --- a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_categories.rs +++ /dev/null @@ -1,143 +0,0 @@ -use actualbudget_models::{ActualbudgetCategory, ActualbudgetZeroBudget}; -use schist_models::Bucket; - -use super::transform_id; - -pub fn transform_categories(actualbudget_categories: &[ActualbudgetCategory]) -> Vec { - actualbudget_categories - .iter() - .map(|actualbudget_category| Bucket { - id: transform_id(&actualbudget_category.id.clone()), - balance: None, - balance_cache_key: None, - group: String::from("un-named group"), - name: actualbudget_category.name.clone(), - }) - .collect() -} - -fn get_budget_quantity( - actualbudget_category: &ActualbudgetCategory, - actualbudget_zero_budgets: &[ActualbudgetZeroBudget], -) -> i32 { - let latest_zero_budget_for_category = actualbudget_zero_budgets - .iter() - .filter(|zb| zb.category_id == actualbudget_category.id) - .max_by_key(|zb| zb.month.clone()); - - match latest_zero_budget_for_category { - Some(latest_zero_budget_for_category) => latest_zero_budget_for_category.amount, - None => 0, - } -} - -#[cfg(test)] -mod test { - use actualbudget_fakes::actualbudget_category::make_fake_actualbudget_category_builder; - - use crate::transform_state::transform_id::transform_id; - - use super::transform_categories; - - #[test] - fn when_one_category_one_zero_budget_then_transforms_category() { - let actualbudget_categories = [make_fake_actualbudget_category_builder("0") - .name(String::from("Food")) - .build() - .unwrap()]; - - let buckets = transform_categories(&actualbudget_categories); - - assert_eq!(1, buckets.len()); - let bucket = buckets.get(0).unwrap(); - assert_eq!(None, bucket.balance); - assert_eq!(None, bucket.balance_cache_key); - assert_eq!("Food", bucket.name); - } - - #[test] - fn when_one_category_no_zero_budget_then_transforms_category() { - let actualbudget_categories = [make_fake_actualbudget_category_builder("0") - .name(String::from("Food")) - .build() - .unwrap()]; - - let buckets = transform_categories(&actualbudget_categories); - - assert_eq!(1, buckets.len()); - let bucket = buckets.get(0).unwrap(); - assert_eq!("Food", bucket.name); - assert_eq!(None, bucket.balance); - assert_eq!(None, bucket.balance_cache_key); - assert_eq!("un-named group", bucket.group); - } - - #[test] - fn when_one_category_multiple_zero_budgets_then_transforms_category() { - let actualbudget_categories = [make_fake_actualbudget_category_builder("0") - .name(String::from("Food")) - .build() - .unwrap()]; - - let buckets = transform_categories(&actualbudget_categories); - - assert_eq!(1, buckets.len()); - let bucket = buckets.get(0).unwrap(); - assert_eq!("Food", bucket.name); - assert_eq!(None, bucket.balance); - assert_eq!(None, bucket.balance_cache_key); - assert_eq!("un-named group", bucket.group); - } - - #[test] - fn when_multiple_categories_multiple_zero_budgets_then_transforms_category() { - let actualbudget_categories = [ - make_fake_actualbudget_category_builder("0") - .name(String::from("Food")) - .build() - .unwrap(), - make_fake_actualbudget_category_builder("1") - .name(String::from("Clothes")) - .build() - .unwrap(), - make_fake_actualbudget_category_builder("2") - .name(String::from("Rent")) - .build() - .unwrap(), - ]; - - let buckets = transform_categories(&actualbudget_categories); - - assert_eq!(3, buckets.len()); - - let bucket_0 = buckets - .iter() - .filter(|c| c.id == transform_id("0")) - .last() - .unwrap(); - assert_eq!("Food", bucket_0.name); - assert_eq!(None, bucket_0.balance); - assert_eq!(None, bucket_0.balance_cache_key); - assert_eq!("un-named group", bucket_0.group); - - let bucket_1 = buckets - .iter() - .filter(|c| c.id == transform_id("1")) - .last() - .unwrap(); - assert_eq!("Clothes", bucket_1.name); - assert_eq!(None, bucket_1.balance); - assert_eq!(None, bucket_1.balance_cache_key); - assert_eq!("un-named group", bucket_1.group); - - let bucket_2 = buckets - .iter() - .filter(|c| c.id == transform_id("2")) - .last() - .unwrap(); - assert_eq!("Rent", bucket_2.name); - assert_eq!(None, bucket_2.balance); - assert_eq!(None, bucket_2.balance_cache_key); - assert_eq!("un-named group", bucket_2.group); - } -} diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_categories_to_buckets.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_categories_to_buckets.rs new file mode 100644 index 0000000..5653c26 --- /dev/null +++ b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_categories_to_buckets.rs @@ -0,0 +1,263 @@ +use actualbudget_models::{ActualbudgetCategory, ActualbudgetCategoryGroup}; +use schist_models::{Bucket, Drip, Transaction}; + +use super::transform_id; + +pub fn transform_categories_to_buckets( + actualbudget_categories: &[ActualbudgetCategory], + actualbudget_category_groups: &[ActualbudgetCategoryGroup], + drips: &[Drip], + transactions: &[Transaction], +) -> Vec { + actualbudget_categories + .iter() + .map(|actualbudget_category| Bucket { + id: transform_id(&actualbudget_category.id.clone()), + balance: None, + balance_cache_key: None, + group: actualbudget_category.group_id.clone() + .map_or_else( + default_group, + |group_id| actualbudget_category_groups + .iter() + .find(|&group| group.id == group_id) + .map_or_else(default_group, |group| group.name.clone().unwrap_or_else(default_group)), + ), + name: actualbudget_category.name.clone(), + }) + .filter(|bucket| !bucket.is_empty(drips, transactions)) + .collect() +} + +fn default_group() -> String { + String::from("un-named group") +} + +#[cfg(test)] +mod test { + use actualbudget_fakes::actualbudget_category::make_fake_actualbudget_category_builder; + use actualbudget_models::ActualbudgetCategoryGroup; + use schist_fakes::{drip::make_fake_drip_builder, transaction::make_fake_transaction_builder}; + use schist_models::Bucket; + + use crate::transform_state::transform_id::transform_id; + + use super::transform_categories_to_buckets; + + #[test] + fn when_one_category_one_drip_then_transforms_category() { + let actualbudget_categories = [make_fake_actualbudget_category_builder("0") + .name(String::from("Food")) + .build() + .unwrap()]; + let actualbudget_category_groups = vec![]; + let drips = [make_fake_drip_builder(0) + .bucket_id(transform_id("0")) + .build() + .unwrap()]; + let transactions = [make_fake_transaction_builder(0) + .bucket_id(Some(transform_id("1"))) + .build() + .unwrap()]; + + let buckets = transform_categories_to_buckets( + &actualbudget_categories, + &actualbudget_category_groups, + &drips, + &transactions); + + assert_eq!(1, buckets.len()); + let bucket = buckets.get(0).unwrap(); + assert_eq!(None, bucket.balance); + assert_eq!(None, bucket.balance_cache_key); + assert_eq!("Food", bucket.name); + } + + #[test] + fn when_one_category_one_transaction_then_transforms_category() { + let actualbudget_categories = [make_fake_actualbudget_category_builder("0") + .name(String::from("Food")) + .build() + .unwrap()]; + let actualbudget_category_groups = vec![]; + let drips = [make_fake_drip_builder(0) + .bucket_id(transform_id("2")) + .build() + .unwrap()]; + let transactions = [make_fake_transaction_builder(0) + .bucket_id(Some(transform_id("0"))) + .build() + .unwrap()]; + + let buckets = transform_categories_to_buckets( + &actualbudget_categories, + &actualbudget_category_groups, + &drips, + &transactions); + + assert_eq!(1, buckets.len()); + let bucket = buckets.get(0).unwrap(); + assert_eq!("Food", bucket.name); + assert_eq!(None, bucket.balance); + assert_eq!(None, bucket.balance_cache_key); + assert_eq!("un-named group", bucket.group); + } + + #[test] + fn when_one_category_no_drip_no_transaction_then_ignores_category() { + let actualbudget_categories = [make_fake_actualbudget_category_builder("0") + .name(String::from("Food")) + .build() + .unwrap()]; + let actualbudget_category_groups = vec![]; + let drips = [make_fake_drip_builder(0) + .bucket_id(transform_id("2")) + .build() + .unwrap()]; + let transactions = [make_fake_transaction_builder(0) + .bucket_id(Some(transform_id("1"))) + .build() + .unwrap()]; + + let buckets = transform_categories_to_buckets( + &actualbudget_categories, + &actualbudget_category_groups, + &drips, + &transactions); + + assert_eq!(0, buckets.len()); + } + + #[test] + fn when_multiple_categories_then_transforms_categories() { + let actualbudget_categories = [ + make_fake_actualbudget_category_builder("0") + .name(String::from("Food")) + .build() + .unwrap(), + make_fake_actualbudget_category_builder("1") + .name(String::from("Clothes")) + .build() + .unwrap(), + make_fake_actualbudget_category_builder("2") + .name(String::from("Rent")) + .build() + .unwrap(), + make_fake_actualbudget_category_builder("3") + .name(String::from("Fragrance")) + .build() + .unwrap(), + ]; + let actualbudget_category_groups = vec![]; + let drips = [ + make_fake_drip_builder(0) + .bucket_id(transform_id("0")) + .build() + .unwrap(), + make_fake_drip_builder(0) + .bucket_id(transform_id("1")) + .build() + .unwrap(), + make_fake_drip_builder(0) + .bucket_id(transform_id("1")) + .build() + .unwrap(), + ]; + let transactions = [ + make_fake_transaction_builder(0) + .bucket_id(Some(transform_id("0"))) + .build() + .unwrap(), + make_fake_transaction_builder(0) + .bucket_id(Some(transform_id("0"))) + .build() + .unwrap(), + make_fake_transaction_builder(0) + .bucket_id(Some(transform_id("2"))) + .build() + .unwrap(), + ]; + + let buckets = transform_categories_to_buckets( + &actualbudget_categories, + &actualbudget_category_groups, + &drips, + &transactions); + + assert_eq!(3, buckets.len()); + + let bucket_0 = buckets + .iter() + .filter(|c| c.id == transform_id("0")) + .last() + .unwrap(); + assert_eq!("Food", bucket_0.name); + assert_eq!(None, bucket_0.balance); + assert_eq!(None, bucket_0.balance_cache_key); + assert_eq!("un-named group", bucket_0.group); + + let bucket_1 = buckets + .iter() + .filter(|c| c.id == transform_id("1")) + .last() + .unwrap(); + assert_eq!("Clothes", bucket_1.name); + assert_eq!(None, bucket_1.balance); + assert_eq!(None, bucket_1.balance_cache_key); + assert_eq!("un-named group", bucket_1.group); + + let bucket_2 = buckets + .iter() + .filter(|c| c.id == transform_id("2")) + .last() + .unwrap(); + assert_eq!("Rent", bucket_2.name); + assert_eq!(None, bucket_2.balance); + assert_eq!(None, bucket_2.balance_cache_key); + assert_eq!("un-named group", bucket_2.group); + } + + #[test] + fn when_groups_include_match_then_includes_group_name() { + let actualbudget_categories = [make_fake_actualbudget_category_builder("0") + .name(String::from("Food")) + .group_id(Some(String::from("group-1"))) + .build() + .unwrap()]; + let actualbudget_category_groups = vec![ + ActualbudgetCategoryGroup { + id: String::from("group-0"), + name: Some(String::from("Income")), + }, + ActualbudgetCategoryGroup { + id: String::from("group-1"), + name: Some(String::from("Essentials")), + } + ]; + let drips = [make_fake_drip_builder(0) + .bucket_id(transform_id("0")) + .build() + .unwrap()]; + let transactions = [make_fake_transaction_builder(0) + .bucket_id(Some(transform_id("1"))) + .build() + .unwrap()]; + + let buckets = transform_categories_to_buckets( + &actualbudget_categories, + &actualbudget_category_groups, + &drips, + &transactions); + + assert_eq!(1, buckets.len()); + let bucket = buckets.get(0).unwrap(); + assert_eq!(Bucket { + id: transform_id("0"), + balance: None, + balance_cache_key: None, + group: String::from("Essentials"), + name: String::from("Food"), + }, *bucket); + + } +} diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_category_transfers.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_category_transfers.rs deleted file mode 100644 index 78b466d..0000000 --- a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_category_transfers.rs +++ /dev/null @@ -1,408 +0,0 @@ -use actualbudget_models::{ActualbudgetAccount, ActualbudgetTransaction}; -use anyhow::{bail, Context, Result}; -use itertools::Itertools; -use schist_models::BucketTransfer; - -use super::{is_category_transfer, transform_id}; - -pub fn transform_category_transfers( - actualbudget_transactions: &[ActualbudgetTransaction], - actualbudget_category_transfers_account: &Option, -) -> Result> { - let actualbudget_category_transfers = actualbudget_transactions.iter().filter(|transaction| { - is_category_transfer(transaction, actualbudget_category_transfers_account) - }); - let actualbudget_parent_category_transfers: Vec = - actualbudget_category_transfers - .clone() - .filter(|t| t.is_parent) - .map(ActualbudgetTransaction::clone) - .collect(); - let actualbudget_child_category_transfers: Vec = - actualbudget_category_transfers - .filter(|t| t.is_child) - .map(ActualbudgetTransaction::clone) - .collect(); - - Ok(actualbudget_parent_category_transfers - .iter() - .map(|pct| transform_category_transfer(pct, &actualbudget_child_category_transfers)) - .filter_ok(Option::is_some) - .map_ok(Option::unwrap) - .collect::>>() - .with_context(|| "failed to transform category transfers")?) -} - -fn transform_category_transfer( - actualbudget_parent_category_transfer: &ActualbudgetTransaction, - actualbudget_child_category_transfers: &[ActualbudgetTransaction], -) -> Result> { - transform_category_transfer_without_context( - actualbudget_parent_category_transfer, - actualbudget_child_category_transfers, - ) - .with_context(|| "failed to transform category transfer") -} - -fn transform_category_transfer_without_context( - actualbudget_parent_category_transfer: &ActualbudgetTransaction, - actualbudget_child_category_transfers: &[ActualbudgetTransaction], -) -> Result> { - let actualbudget_child_category_transfers = actualbudget_child_category_transfers - .iter() - .filter(|t| { - t.parent_id - .clone() - .is_some_and(|parent_id| parent_id == actualbudget_parent_category_transfer.id) - }) - .map(ActualbudgetTransaction::clone); - - let from_transfers: Vec = actualbudget_child_category_transfers - .clone() - .filter(|t| t.amount < 0) - .collect(); - if from_transfers.len() != 1 { - bail!( - "actualbudget parent category transfer {} had {} from child transfers", - actualbudget_parent_category_transfer.id, - from_transfers.len(), - ); - } - let from_transfer = from_transfers.get(0).unwrap(); - - if from_transfer.category_id.is_none() { - bail!( - "actualbudget child from transfer {} had no category ID", - from_transfer.id - ); - } - let from_category_id: i32 = transform_id(&from_transfer.category_id.clone().unwrap()); - - let to_transfers: Vec = actualbudget_child_category_transfers - .clone() - .filter(|t| t.amount > 0) - .collect(); - if to_transfers.len() != 1 { - bail!( - "actualbudget parent category transfer {} had {} to child transfers", - actualbudget_parent_category_transfer.id, - to_transfers.len(), - ); - } - let to_transfer = to_transfers.get(0).unwrap(); - - if to_transfer.category_id.is_none() { - bail!( - "actualbudget child to transfer {} had no category ID", - to_transfer.id - ); - } - let to_category_id: i32 = transform_id(&to_transfer.category_id.clone().unwrap()); - - if from_transfer.amount != -to_transfer.amount { - bail!("actualbudget category transfer amounts did not sum to zero"); - } - - Ok(Some(BucketTransfer { - id: transform_id(&actualbudget_parent_category_transfer.id), - description: actualbudget_parent_category_transfer - .notes - .clone() - .unwrap_or_else(String::new), - amount: to_transfer.amount.try_into().unwrap(), - from_bucket_id: from_category_id, - to_bucket_id: to_category_id, - })) -} - -#[cfg(test)] -mod test { - use actualbudget_fakes::{ - actualbudget_account::make_fake_actualbudget_account, - actualbudget_transaction::make_fake_actualbudget_transaction_builder, - }; - use actualbudget_models::{ActualbudgetTransaction, ActualbudgetTransactionBuilder}; - use schist_models::BucketTransfer; - - use crate::transform_state::transform_id::transform_id; - - use super::transform_category_transfers; - - #[test] - fn when_one_category_transfer_then_transforms() { - let actualbudget_category_transfers_account = make_fake_actualbudget_account("0"); - let actualbudget_transactions = [ - make_fake_parent_transaction("0"), - make_fake_from_child_transaction("1", "0"), - make_fake_to_child_transaction("2", "0"), - ]; - - let result = transform_category_transfers( - &actualbudget_transactions, - &Some(actualbudget_category_transfers_account), - ); - - assert!(result.is_ok()); - let bucket_transfers = result.unwrap(); - assert_eq!(1, bucket_transfers.len()); - let bucket_transfer = bucket_transfers.get(0).unwrap(); - assert_eq!(100, bucket_transfer.amount); - assert_eq!("I was a parent transaction", bucket_transfer.description); - assert_eq!(transform_id("0"), bucket_transfer.from_bucket_id); - assert_eq!(transform_id("1"), bucket_transfer.to_bucket_id); - } - - #[test] - fn when_many_transactions_then_transforms() { - let actualbudget_category_transfers_account = make_fake_actualbudget_account("0"); - let actualbudget_transactions = [ - make_fake_parent_transaction("0"), - make_fake_from_child_transaction_builder("1", 100, "0") - .category_id(Some(String::from("0"))) - .build() - .unwrap(), - make_fake_to_child_transaction_builder("2", 100, "0") - .category_id(Some(String::from("1"))) - .build() - .unwrap(), - make_fake_parent_transaction("3"), - make_fake_from_child_transaction_builder("4", 200, "3") - .category_id(Some(String::from("1"))) - .build() - .unwrap(), - make_fake_to_child_transaction_builder("5", 200, "3") - .category_id(Some(String::from("0"))) - .build() - .unwrap(), - make_fake_actualbudget_transaction_builder("6") - .amount(300) - .build() - .unwrap(), - make_fake_actualbudget_transaction_builder("7") - .amount(-400) - .build() - .unwrap(), - ]; - - let result = transform_category_transfers( - &actualbudget_transactions, - &Some(actualbudget_category_transfers_account), - ); - - assert!(result.is_ok()); - let bucket_transfers = result.unwrap(); - assert_eq!(2, bucket_transfers.len()); - - let bucket_transfer_0: Vec<&BucketTransfer> = bucket_transfers - .iter() - .filter(|ct| ct.amount == 100) - .collect(); - assert_eq!(1, bucket_transfer_0.len()); - let bucket_transfer_0 = bucket_transfer_0.get(0).unwrap(); - assert_eq!(transform_id("0"), bucket_transfer_0.from_bucket_id); - assert_eq!(transform_id("1"), bucket_transfer_0.to_bucket_id); - - let bucket_transfer_1: Vec<&BucketTransfer> = bucket_transfers - .iter() - .filter(|ct| ct.amount == 200) - .collect(); - assert_eq!(1, bucket_transfer_1.len()); - let bucket_transfer_1 = bucket_transfer_1.get(0).unwrap(); - assert_eq!(transform_id("1"), bucket_transfer_1.from_bucket_id); - assert_eq!(transform_id("0"), bucket_transfer_1.to_bucket_id); - } - - #[test] - fn when_multiple_child_from_transactions_then_err() { - let actualbudget_category_transfers_account = make_fake_actualbudget_account("0"); - let actualbudget_transactions = [ - make_fake_parent_transaction("0"), - make_fake_from_child_transaction_builder("1", 50, "0") - .build() - .unwrap(), - make_fake_from_child_transaction_builder("2", 50, "0") - .build() - .unwrap(), - make_fake_to_child_transaction_builder("3", 100, "0") - .build() - .unwrap(), - ]; - - let result = transform_category_transfers( - &actualbudget_transactions, - &Some(actualbudget_category_transfers_account), - ); - - assert!(result.is_err()); - } - - #[test] - fn when_multiple_child_to_transactions_then_err() { - let actualbudget_category_transfers_account = make_fake_actualbudget_account("0"); - let actualbudget_transactions = [ - make_fake_parent_transaction("0"), - make_fake_from_child_transaction_builder("1", 100, "0") - .build() - .unwrap(), - make_fake_to_child_transaction_builder("2", 50, "0") - .build() - .unwrap(), - make_fake_to_child_transaction_builder("3", 50, "0") - .build() - .unwrap(), - ]; - - let result = transform_category_transfers( - &actualbudget_transactions, - &Some(actualbudget_category_transfers_account), - ); - - assert!(result.is_err()); - } - - #[test] - fn when_no_child_from_transactions_then_err() { - let actualbudget_category_transfers_account = make_fake_actualbudget_account("0"); - let actualbudget_transactions = [ - make_fake_parent_transaction("0"), - make_fake_to_child_transaction("1", "0"), - ]; - - let result = transform_category_transfers( - &actualbudget_transactions, - &Some(actualbudget_category_transfers_account), - ); - - assert!(result.is_err()); - } - - #[test] - fn when_no_child_to_transactions_then_err() { - let actualbudget_category_transfers_account = make_fake_actualbudget_account("0"); - let actualbudget_transactions = [ - make_fake_parent_transaction("0"), - make_fake_from_child_transaction("1", "0"), - ]; - - let result = transform_category_transfers( - &actualbudget_transactions, - &Some(actualbudget_category_transfers_account), - ); - - assert!(result.is_err()); - } - - #[test] - fn when_child_amounts_dont_add_up_to_zero_then_err() { - let actualbudget_category_transfers_account = make_fake_actualbudget_account("0"); - let actualbudget_transactions = [ - make_fake_parent_transaction("0"), - make_fake_from_child_transaction_builder("1", 50, "0") - .build() - .unwrap(), - make_fake_to_child_transaction_builder("2", 100, "0") - .build() - .unwrap(), - ]; - - let result = transform_category_transfers( - &actualbudget_transactions, - &Some(actualbudget_category_transfers_account), - ); - - assert!(result.is_err()); - } - - #[test] - fn when_child_category_transfer_has_no_category_then_err() { - let actualbudget_category_transfers_account = make_fake_actualbudget_account("0"); - let actualbudget_transactions_0 = [ - make_fake_parent_transaction("0"), - make_fake_from_child_transaction_builder("1", 100, "0") - .category_id(None) - .build() - .unwrap(), - make_fake_to_child_transaction_builder("2", 100, "0") - .build() - .unwrap(), - ]; - let actualbudget_transactions_1 = [ - make_fake_parent_transaction("0"), - make_fake_from_child_transaction_builder("1", 100, "0") - .build() - .unwrap(), - make_fake_to_child_transaction_builder("2", 100, "0") - .category_id(None) - .build() - .unwrap(), - ]; - - let result_0 = transform_category_transfers( - &actualbudget_transactions_0, - &Some(actualbudget_category_transfers_account.clone()), - ); - let result_1 = transform_category_transfers( - &actualbudget_transactions_1, - &Some(actualbudget_category_transfers_account), - ); - - assert!(result_0.is_err()); - assert!(result_1.is_err()); - } - - fn make_fake_parent_transaction(id: &str) -> ActualbudgetTransaction { - make_fake_parent_transaction_builder(id).build().unwrap() - } - - fn make_fake_parent_transaction_builder(id: &str) -> ActualbudgetTransactionBuilder { - let mut builder = make_fake_actualbudget_transaction_builder(id); - builder - .account_id(String::from("0")) - .notes(Some(String::from("I was a parent transaction"))) - .is_parent(true); - builder - } - - fn make_fake_from_child_transaction(id: &str, parent_id: &str) -> ActualbudgetTransaction { - make_fake_from_child_transaction_builder(id, 100, parent_id) - .build() - .unwrap() - } - fn make_fake_from_child_transaction_builder( - id: &str, - amount: i32, - parent_id: &str, - ) -> ActualbudgetTransactionBuilder { - let mut builder = make_fake_actualbudget_transaction_builder(id); - builder - .account_id(String::from("0")) - .category_id(Some(String::from("0"))) - .amount(-amount) - .is_child(true) - .notes(Some(String::from("I was a from child transaction"))) - .parent_id(Some(String::from(parent_id))); - builder - } - - fn make_fake_to_child_transaction(id: &str, parent_id: &str) -> ActualbudgetTransaction { - make_fake_to_child_transaction_builder(id, 100, parent_id) - .build() - .unwrap() - } - - fn make_fake_to_child_transaction_builder( - id: &str, - amount: i32, - parent_id: &str, - ) -> ActualbudgetTransactionBuilder { - let mut builder = make_fake_actualbudget_transaction_builder(id); - builder - .account_id(String::from("0")) - .category_id(Some(String::from("1"))) - .amount(amount) - .is_child(true) - .notes(Some(String::from("I was a to child transaction"))) - .parent_id(Some(String::from(parent_id))); - builder - } -} diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_state.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_state.rs index 7816df6..835ceff 100644 --- a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_state.rs +++ b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_state.rs @@ -1,12 +1,12 @@ -use anyhow::{bail, Context, Result}; -use schist_models::{Account, Bucket, BucketTransfer, DateUtc, Pipe, Transaction}; +use anyhow::{Context, Result}; +use schist_models::{Account, Bucket, BucketTransfer, Pipe, Transaction}; use crate::{actualbudget_state::ActualbudgetState, schist_state::SchistState}; use super::{ - transform_account_transfers, transform_accounts, - transform_budget_drips::transform_budget_drips, transform_categories, - transform_category_transfers, transform_date, transform_transactions, transform_zero_budgets, + transform_account_transfers, transform_accounts, transform_categories_to_buckets, + transform_date, transform_transactions, transform_transactions_to_bucket_transfers, + transform_zero_budgets_to_drips::transform_budget_drips, transform_zero_budgets_to_pipes, }; pub fn transform_state(actualbudget_state: ActualbudgetState) -> Result { @@ -17,25 +17,27 @@ pub fn transform_state(actualbudget_state: ActualbudgetState) -> Result Result { let (remaining_transactions, account_transfers) = transform_account_transfers(&actualbudget_state.actualbudget_transactions)?; + let transactions: Vec = transform_transactions( + &remaining_transactions, + &actualbudget_state.actualbudget_payees, + )?; let first_activity_date = transform_date(&actualbudget_state.actualbudget_first_activity_date)?; - let before_first_activity_date = find_date_before(&first_activity_date)?; let accounts: Vec = transform_accounts( &actualbudget_state.actualbudget_accounts, - &actualbudget_state.actualbudget_category_transfers_account, + &actualbudget_state.actualbudget_transactions, &first_activity_date, ); let budget_drips = transform_budget_drips(&actualbudget_state.actualbudget_zero_budgets)?; - let buckets: Vec = transform_categories(&actualbudget_state.actualbudget_categories); - let pipes: Vec = transform_zero_budgets(&actualbudget_state.actualbudget_zero_budgets); - let transactions: Vec = transform_transactions( - &remaining_transactions, - &actualbudget_state.actualbudget_category_transfers_account, - &actualbudget_state.actualbudget_payees, - )?; - let bucket_transfers: Vec = transform_category_transfers( - &remaining_transactions, - &actualbudget_state.actualbudget_category_transfers_account, - )?; + let buckets: Vec = transform_categories_to_buckets( + &actualbudget_state.actualbudget_categories, + &actualbudget_state.actualbudget_category_groups, + &budget_drips, + &transactions, + ); + let pipes: Vec = + transform_zero_budgets_to_pipes(&actualbudget_state.actualbudget_zero_budgets)?; + let bucket_transfers: Vec = + transform_transactions_to_bucket_transfers(&remaining_transactions)?; Ok(SchistState { first_transaction_date: first_activity_date, @@ -48,14 +50,3 @@ fn transform_state_without_context(actualbudget_state: ActualbudgetState) -> Res bucket_transfers, }) } - -fn find_date_before(date: &DateUtc) -> Result { - for days_before in 1..31 { - let result = date.sub_days(days_before); - if result.is_ok() { - return result; - } - } - - bail!("failed to find a date before {}", date) -} diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_transactions.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_transactions.rs index 7c4ece5..11765be 100644 --- a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_transactions.rs +++ b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_transactions.rs @@ -1,32 +1,27 @@ -use actualbudget_models::{ActualbudgetAccount, ActualbudgetPayee, ActualbudgetTransaction}; +use actualbudget_models::{ActualbudgetPayee, ActualbudgetTransaction}; use anyhow::{Context, Result}; use schist_models::Transaction; -use super::{is_not_category_transfer, transform_date, transform_id}; +use super::{is_not_bucket_transfer, transform_date, transform_id}; pub fn transform_transactions( actualbudget_transactions: &[ActualbudgetTransaction], - actualbudget_category_transfers_account: &Option, actualbudget_payees: &[ActualbudgetPayee], ) -> Result> { - transform_transactions_without_context( - actualbudget_transactions, - actualbudget_category_transfers_account, - actualbudget_payees, - ) - .with_context(|| "failed to transform actualbudget transactions to schist transactions") + transform_transactions_without_context(actualbudget_transactions, actualbudget_payees) + .with_context(|| "failed to transform actualbudget transactions to schist transactions") } fn transform_transactions_without_context( actualbudget_transactions: &[ActualbudgetTransaction], - actualbudget_category_transfers_account: &Option, actualbudget_payees: &[ActualbudgetPayee], ) -> Result> { let mut transactions = Vec::::new(); let actualbudget_transactions = actualbudget_transactions .iter() - .filter(|t| is_not_category_transfer(t, actualbudget_category_transfers_account)); + .filter(|t| !t.is_starting_balance()) + .filter(|t| is_not_bucket_transfer(t, actualbudget_transactions)); let parent_actualbudget_transactions: Vec<&ActualbudgetTransaction> = actualbudget_transactions .clone() .filter(|t| t.is_parent) @@ -60,7 +55,10 @@ fn transform_transactions_without_context( amount: atomic_actualbudget_transaction.amount.try_into().unwrap(), date: transform_date(&atomic_actualbudget_transaction.date)?, account_id: transform_id(&atomic_actualbudget_transaction.account_id), - bucket_id: None, + bucket_id: atomic_actualbudget_transaction + .category_id + .clone() + .map(|id| transform_id(id.as_str())), }); } @@ -90,36 +88,16 @@ fn transform_transactions_without_context( #[cfg(test)] mod test { use actualbudget_fakes::{ - actualbudget_account::make_fake_actualbudget_account, - actualbudget_payee::{make_fake_actualbudget_payee, make_fake_actualbudget_payee_builder}, + actualbudget_payee::make_fake_actualbudget_payee_builder, actualbudget_transaction::make_fake_actualbudget_transaction_builder, }; use actualbudget_models::ActualbudgetDate; - use schist_models::DateUtc; + use schist_models::{DateUtc, Transaction}; use crate::transform_state::transform_id::transform_id; use super::transform_transactions; - #[test] - fn when_one_category_transfer_transaction_then_returns_empty() { - let actualbudget_transactions = [make_fake_actualbudget_transaction_builder("0") - .category_id(Some(String::from("0"))) - .build() - .unwrap()]; - let actualbudget_category_transfers_account = make_fake_actualbudget_account("0"); - let actualbudget_payees = [make_fake_actualbudget_payee("0")]; - - let transactions = transform_transactions( - &actualbudget_transactions, - &Some(actualbudget_category_transfers_account), - &actualbudget_payees, - ) - .unwrap(); - - assert_eq!(0, transactions.len()); - } - #[test] fn when_one_atomic_transaction_then_returns_transformed_transaction() { let actualbudget_transactions = [make_fake_actualbudget_transaction_builder("0") @@ -137,8 +115,7 @@ mod test { .unwrap()]; let transactions = - transform_transactions(&actualbudget_transactions, &None, &actualbudget_payees) - .unwrap(); + transform_transactions(&actualbudget_transactions, &actualbudget_payees).unwrap(); assert_eq!(1, transactions.len()); let transaction = transactions.get(0).unwrap(); @@ -153,11 +130,14 @@ mod test { fn when_one_split_transaction_then_returns_transformed_transaction() { let actualbudget_transactions = [ make_fake_actualbudget_transaction_builder("0") + .account_id(String::from("0")) + .date(ActualbudgetDate::from_i32(20240801)) .is_parent(true) - .amount(100) + .amount(300) .build() .unwrap(), make_fake_actualbudget_transaction_builder("1") + .account_id(String::from("0")) .amount(200) .category_id(Some(String::from("0"))) .is_child(true) @@ -166,8 +146,9 @@ mod test { .build() .unwrap(), make_fake_actualbudget_transaction_builder("2") - .amount(300) - .category_id(Some(String::from("0"))) + .account_id(String::from("0")) + .amount(100) + .category_id(Some(String::from("1"))) .is_child(true) .parent_id(Some(String::from("0"))) .notes(Some(String::from("notes B"))) @@ -175,11 +156,20 @@ mod test { .unwrap(), ]; - let transactions = transform_transactions(&actualbudget_transactions, &None, &[]).unwrap(); + let transactions = transform_transactions(&actualbudget_transactions, &[]).unwrap(); assert_eq!(1, transactions.len()); - - let transaction = transactions.get(0).unwrap(); - assert_eq!(100, transaction.amount); + assert_eq!( + Some(Transaction { + id: transform_id("0"), + account_id: transform_id("0"), + amount: 300, + bucket_id: None, + counterparty: String::from(""), + date: "2024-08-01".parse().unwrap(), + description: String::from(""), + }), + transactions.get(0).cloned(), + ); } } diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_transactions_to_bucket_transfers.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_transactions_to_bucket_transfers.rs new file mode 100644 index 0000000..51545c3 --- /dev/null +++ b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_transactions_to_bucket_transfers.rs @@ -0,0 +1,383 @@ +use actualbudget_models::ActualbudgetTransaction; +use anyhow::{bail, Context, Result}; +use itertools::Itertools; +use schist_models::BucketTransfer; + +use super::transform_id; + +pub fn transform_transactions_to_bucket_transfers( + actualbudget_transactions: &[ActualbudgetTransaction], +) -> Result> { + let children: Vec<&ActualbudgetTransaction> = actualbudget_transactions + .into_iter() + .filter(|&t| t.is_child) + .collect(); + let parent_children_pairs: Vec<(&ActualbudgetTransaction, Vec<&ActualbudgetTransaction>)> = + actualbudget_transactions + .iter() + .filter(|t| t.is_parent) + .map(|parent| { + ( + parent, + children + .clone() + .into_iter() + .filter(|&c| c.parent_id.clone().is_some_and(|id| id == parent.id)) + .collect::>(), + ) + }) + .map(|(parent, children)| { + let children_total_amount: i32 = children.iter().map(|child| child.amount).sum(); + if parent.amount != children_total_amount { + bail!("parent transaction {} has amount: {}, but its {} children add up to a total amount of: {}", + parent.id, + parent.amount, + children.len(), + children_total_amount); + } + Ok((parent, children)) + }) + .filter(|result| + match result { + Ok((parent, _children)) => parent.amount == 0, + Err(_) => true, + }) + .try_collect()?; + + Ok(parent_children_pairs + .iter() + .map(|(parent, children)| transform_category_transfer(parent, children)) + .filter_ok(Option::is_some) + .map_ok(Option::unwrap) + .collect::>>() + .with_context(|| "failed to transform category transfers")?) +} + +fn transform_category_transfer( + actualbudget_parent_category_transfer: &ActualbudgetTransaction, + actualbudget_child_category_transfers: &[&ActualbudgetTransaction], +) -> Result> { + transform_category_transfer_without_context( + actualbudget_parent_category_transfer, + actualbudget_child_category_transfers, + ) + .with_context(|| "failed to transform category transfer") +} + +fn transform_category_transfer_without_context( + actualbudget_parent_category_transfer: &ActualbudgetTransaction, + actualbudget_child_category_transfers: &[&ActualbudgetTransaction], +) -> Result> { + let from_transfers: Vec<&ActualbudgetTransaction> = actualbudget_child_category_transfers + .into_iter() + .cloned() + .filter(|&t| t.amount < 0) + .collect(); + if from_transfers.len() != 1 { + bail!( + "actualbudget parent category transfer {} had {} from child transfers", + actualbudget_parent_category_transfer.id, + from_transfers.len(), + ); + } + let from_transfer = from_transfers.get(0).unwrap(); + + if from_transfer.category_id.is_none() { + bail!( + "actualbudget child from transfer {} had no category ID", + from_transfer.id + ); + } + let from_category_id: i32 = transform_id(&from_transfer.category_id.clone().unwrap()); + + let to_transfers: Vec<&ActualbudgetTransaction> = actualbudget_child_category_transfers + .iter() + .filter(|&t| t.amount > 0) + .cloned() + .collect(); + if to_transfers.len() != 1 { + bail!( + "actualbudget parent category transfer {} had {} to child transfers", + actualbudget_parent_category_transfer.id, + to_transfers.len(), + ); + } + let to_transfer = to_transfers.get(0).unwrap(); + + if to_transfer.category_id.is_none() { + bail!( + "actualbudget child to transfer {} had no category ID", + to_transfer.id + ); + } + let to_category_id: i32 = transform_id(&to_transfer.category_id.clone().unwrap()); + + if from_transfer.amount != -to_transfer.amount { + bail!("actualbudget category transfer amounts did not sum to zero"); + } + + Ok(Some(BucketTransfer { + id: transform_id(&actualbudget_parent_category_transfer.id), + description: actualbudget_parent_category_transfer + .notes + .clone() + .unwrap_or_else(String::new), + amount: to_transfer.amount.try_into().unwrap(), + from_bucket_id: from_category_id, + to_bucket_id: to_category_id, + })) +} + +#[cfg(test)] +mod test { + use actualbudget_fakes::actualbudget_transaction::make_fake_actualbudget_transaction_builder; + use actualbudget_models::{ActualbudgetTransaction, ActualbudgetTransactionBuilder}; + use schist_models::BucketTransfer; + + use crate::transform_state::transform_id::transform_id; + + use super::transform_transactions_to_bucket_transfers; + + #[test] + fn when_one_category_transfer_then_transforms() { + let actualbudget_transactions = [ + make_fake_parent_transaction("0"), + make_fake_from_child_transaction("1", "0"), + make_fake_to_child_transaction("2", "0"), + ]; + + let result = transform_transactions_to_bucket_transfers(&actualbudget_transactions); + + assert!(result.is_ok()); + let bucket_transfers = result.unwrap(); + assert_eq!(1, bucket_transfers.len()); + let bucket_transfer = bucket_transfers.get(0).unwrap(); + assert_eq!(100, bucket_transfer.amount); + assert_eq!("I was a parent transaction", bucket_transfer.description); + assert_eq!(transform_id("0"), bucket_transfer.from_bucket_id); + assert_eq!(transform_id("1"), bucket_transfer.to_bucket_id); + } + + #[test] + fn when_many_transactions_then_transforms() { + let actualbudget_transactions = [ + make_fake_parent_transaction("0"), + make_fake_from_child_transaction_builder("1", 100, "0") + .category_id(Some(String::from("0"))) + .build() + .unwrap(), + make_fake_to_child_transaction_builder("2", 100, "0") + .category_id(Some(String::from("1"))) + .build() + .unwrap(), + make_fake_parent_transaction("3"), + make_fake_from_child_transaction_builder("4", 200, "3") + .category_id(Some(String::from("1"))) + .build() + .unwrap(), + make_fake_to_child_transaction_builder("5", 200, "3") + .category_id(Some(String::from("0"))) + .build() + .unwrap(), + make_fake_actualbudget_transaction_builder("6") + .amount(300) + .build() + .unwrap(), + make_fake_actualbudget_transaction_builder("7") + .amount(-400) + .build() + .unwrap(), + ]; + + let result = transform_transactions_to_bucket_transfers(&actualbudget_transactions); + + assert!(result.is_ok()); + let bucket_transfers = result.unwrap(); + assert_eq!(2, bucket_transfers.len()); + + let bucket_transfer_0: Vec<&BucketTransfer> = bucket_transfers + .iter() + .filter(|ct| ct.amount == 100) + .collect(); + assert_eq!(1, bucket_transfer_0.len()); + let bucket_transfer_0 = bucket_transfer_0.get(0).unwrap(); + assert_eq!(transform_id("0"), bucket_transfer_0.from_bucket_id); + assert_eq!(transform_id("1"), bucket_transfer_0.to_bucket_id); + + let bucket_transfer_1: Vec<&BucketTransfer> = bucket_transfers + .iter() + .filter(|ct| ct.amount == 200) + .collect(); + assert_eq!(1, bucket_transfer_1.len()); + let bucket_transfer_1 = bucket_transfer_1.get(0).unwrap(); + assert_eq!(transform_id("1"), bucket_transfer_1.from_bucket_id); + assert_eq!(transform_id("0"), bucket_transfer_1.to_bucket_id); + } + + #[test] + fn when_multiple_child_from_transactions_then_err() { + let actualbudget_transactions = [ + make_fake_parent_transaction("0"), + make_fake_from_child_transaction_builder("1", 50, "0") + .build() + .unwrap(), + make_fake_from_child_transaction_builder("2", 50, "0") + .build() + .unwrap(), + make_fake_to_child_transaction_builder("3", 100, "0") + .build() + .unwrap(), + ]; + + let result = transform_transactions_to_bucket_transfers(&actualbudget_transactions); + + assert!(result.is_err()); + } + + #[test] + fn when_multiple_child_to_transactions_then_err() { + let actualbudget_transactions = [ + make_fake_parent_transaction("0"), + make_fake_from_child_transaction_builder("1", 100, "0") + .build() + .unwrap(), + make_fake_to_child_transaction_builder("2", 50, "0") + .build() + .unwrap(), + make_fake_to_child_transaction_builder("3", 50, "0") + .build() + .unwrap(), + ]; + + let result = transform_transactions_to_bucket_transfers(&actualbudget_transactions); + + assert!(result.is_err()); + } + + #[test] + fn when_no_child_from_transactions_then_err() { + let actualbudget_transactions = [ + make_fake_parent_transaction("0"), + make_fake_to_child_transaction("1", "0"), + ]; + + let result = transform_transactions_to_bucket_transfers(&actualbudget_transactions); + + assert!(result.is_err()); + } + + #[test] + fn when_no_child_to_transactions_then_err() { + let actualbudget_transactions = [ + make_fake_parent_transaction("0"), + make_fake_from_child_transaction("1", "0"), + ]; + + let result = transform_transactions_to_bucket_transfers(&actualbudget_transactions); + + assert!(result.is_err()); + } + + #[test] + fn when_child_amounts_dont_add_up_to_zero_then_err() { + let actualbudget_transactions = [ + make_fake_parent_transaction("0"), + make_fake_from_child_transaction_builder("1", 50, "0") + .build() + .unwrap(), + make_fake_to_child_transaction_builder("2", 100, "0") + .build() + .unwrap(), + ]; + + let result = transform_transactions_to_bucket_transfers(&actualbudget_transactions); + + assert!(result.is_err()); + } + + #[test] + fn when_child_category_transfer_has_no_category_then_err() { + let actualbudget_transactions_0 = [ + make_fake_parent_transaction("0"), + make_fake_from_child_transaction_builder("1", 100, "0") + .category_id(None) + .build() + .unwrap(), + make_fake_to_child_transaction_builder("2", 100, "0") + .build() + .unwrap(), + ]; + let actualbudget_transactions_1 = [ + make_fake_parent_transaction("0"), + make_fake_from_child_transaction_builder("1", 100, "0") + .build() + .unwrap(), + make_fake_to_child_transaction_builder("2", 100, "0") + .category_id(None) + .build() + .unwrap(), + ]; + + let result_0 = transform_transactions_to_bucket_transfers(&actualbudget_transactions_0); + let result_1 = transform_transactions_to_bucket_transfers(&actualbudget_transactions_1); + + assert!(result_0.is_err()); + assert!(result_1.is_err()); + } + + fn make_fake_parent_transaction(id: &str) -> ActualbudgetTransaction { + make_fake_parent_transaction_builder(id).build().unwrap() + } + + fn make_fake_parent_transaction_builder(id: &str) -> ActualbudgetTransactionBuilder { + let mut builder = make_fake_actualbudget_transaction_builder(id); + builder + .account_id(String::from("0")) + .notes(Some(String::from("I was a parent transaction"))) + .is_parent(true); + builder + } + + fn make_fake_from_child_transaction(id: &str, parent_id: &str) -> ActualbudgetTransaction { + make_fake_from_child_transaction_builder(id, 100, parent_id) + .build() + .unwrap() + } + fn make_fake_from_child_transaction_builder( + id: &str, + amount: i32, + parent_id: &str, + ) -> ActualbudgetTransactionBuilder { + let mut builder = make_fake_actualbudget_transaction_builder(id); + builder + .account_id(String::from("0")) + .category_id(Some(String::from("0"))) + .amount(-amount) + .is_child(true) + .notes(Some(String::from("I was a from child transaction"))) + .parent_id(Some(String::from(parent_id))); + builder + } + + fn make_fake_to_child_transaction(id: &str, parent_id: &str) -> ActualbudgetTransaction { + make_fake_to_child_transaction_builder(id, 100, parent_id) + .build() + .unwrap() + } + + fn make_fake_to_child_transaction_builder( + id: &str, + amount: i32, + parent_id: &str, + ) -> ActualbudgetTransactionBuilder { + let mut builder = make_fake_actualbudget_transaction_builder(id); + builder + .account_id(String::from("0")) + .category_id(Some(String::from("1"))) + .amount(amount) + .is_child(true) + .notes(Some(String::from("I was a to child transaction"))) + .parent_id(Some(String::from(parent_id))); + builder + } +} diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_zero_budgets.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_zero_budgets.rs deleted file mode 100644 index 999c3e8..0000000 --- a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_zero_budgets.rs +++ /dev/null @@ -1,6 +0,0 @@ -use actualbudget_models::ActualbudgetZeroBudget; -use schist_models::Pipe; - -pub fn transform_zero_budgets(_actualbudget_zero_budgets: &[ActualbudgetZeroBudget]) -> Vec { - todo!() -} diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_zero_budgets_to_drips.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_zero_budgets_to_drips.rs new file mode 100644 index 0000000..bfaef31 --- /dev/null +++ b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_zero_budgets_to_drips.rs @@ -0,0 +1,325 @@ +use std::hash::{DefaultHasher, Hash, Hasher}; + +use actualbudget_models::ActualbudgetZeroBudget; +use anyhow::{Context, Result}; +use itertools::Itertools; +use schist_models::{DateUtc, Drip}; +use schist_traits::Dateable; + +use super::{transform_date::transform_date, transform_id::transform_id}; + +pub fn transform_budget_drips( + actualbudget_zero_budgets: &[ActualbudgetZeroBudget], +) -> Result> { + let non_zero_actualbudget_zero_budgets: Vec = actualbudget_zero_budgets + .iter() + .filter(|zb| zb.amount != 0) + .cloned() + .collect(); + + let budget_drips = + group_actualbudget_zero_budgets_by_category_id(&non_zero_actualbudget_zero_budgets) + .iter() + .map(sort_actualbudget_zero_budgets_by_month) + .flat_map(|sorted_actualbudget_zero_budgets| { + sorted_actualbudget_zero_budgets + .iter() + .map(map_actualbudget_zero_budget_to_budget_drips) + .collect::>>>() + }) + .flatten() + .flatten() + .filter(|bd| bd.amount != 0) + .collect(); + + Ok(budget_drips) +} + +fn map_actualbudget_zero_budget_to_budget_drips( + actualbudget_zero_budget: &ActualbudgetZeroBudget, +) -> Result> { + let context = || "failed to map actualbudget zero budgets to budget drips"; + + let first_day_of_month: DateUtc = Into::::into( + transform_date(&actualbudget_zero_budget.month).with_context(context)?, + ); + let month = first_day_of_month.month(); + let days_in_month: Vec = (0..31) + .map(|i| first_day_of_month.add_days(i)) + .take_while(|d| match d { + Err(_) => true, + Ok(d) => d.month() == month, + }) + .filter(Result::is_ok) + .map(Result::unwrap) + .collect(); + + // unwrap() can panic, but days_in_month is guaranteed to have between 0 and + // 31 elements, so we should be grand + let num_days_in_month: i32 = days_in_month.len().try_into().unwrap(); + + let amount = actualbudget_zero_budget.amount / num_days_in_month; + let remainder_amount = actualbudget_zero_budget.amount - (amount * num_days_in_month); + + let budget_drips: Vec = days_in_month + .iter() + .map(|&date| Drip { + id: transform_budget_drip_id(&actualbudget_zero_budget, &date), + bucket_id: transform_id(&actualbudget_zero_budget.category_id), + date, + amount: if date == first_day_of_month { + amount + remainder_amount + } else { + amount + }, + inserted_at_unix_seconds: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("The current time should be after the Unix epoch") + .as_secs() + .try_into() + .expect("The current time's timestamp in seconds should fit into a 32-bit signed integer"), + pipe_id: 0, + }) + .collect(); + + Ok(budget_drips) +} + +fn sort_actualbudget_zero_budgets_by_month( + actualbudget_zero_budgets_by_category_id: &Vec, +) -> Vec { + let mut actualbudget_zero_budgets = actualbudget_zero_budgets_by_category_id.clone(); + actualbudget_zero_budgets.sort_by_key(|zb| zb.month.clone()); + actualbudget_zero_budgets +} + +fn group_actualbudget_zero_budgets_by_category_id( + actualbudget_zero_budgets: &[ActualbudgetZeroBudget], +) -> Vec> { + let category_ids = actualbudget_zero_budgets + .iter() + .map(|zb| zb.category_id.clone()) + .unique(); + + category_ids + .map(|category_id| { + actualbudget_zero_budgets + .iter() + .filter(|zb| zb.category_id == category_id) + .map(ActualbudgetZeroBudget::clone) + .collect() + }) + .collect() +} + +fn transform_budget_drip_id( + actualbudget_zero_budget: &ActualbudgetZeroBudget, + date: &DateUtc, +) -> i32 { + let mut hasher = DefaultHasher::new(); + let unique_str = actualbudget_zero_budget.id.clone() + date.to_string().as_str(); + unique_str.hash(&mut hasher); + (hasher.finish() % std::convert::TryInto::::try_into(i32::MAX).unwrap()) + .try_into() + .unwrap() +} + +#[cfg(test)] +mod test { + use actualbudget_fakes::actualbudget_zero_budget::make_fake_actualbudget_zero_budget_builder; + use actualbudget_models::ActualbudgetDate; + use schist_models::Drip; + use schist_traits::Dateable; + + use crate::transform_state::transform_id::transform_id; + + use super::transform_budget_drips; + + mod map_actualbudget_zero_budget_to_budget_drips { + use actualbudget_fakes::actualbudget_zero_budget::make_fake_actualbudget_zero_budget_builder; + use actualbudget_models::ActualbudgetDate; + use schist_models::Drip; + use schist_traits::Dateable; + + use crate::transform_state::transform_zero_budgets_to_drips::map_actualbudget_zero_budget_to_budget_drips; + + #[test] + fn when_zero_budget_with_integer_budget_per_day_then_evenly_divided_budget_drips() { + let actualbudget_zero_budget = make_fake_actualbudget_zero_budget_builder("0") + .amount(31 * 100) + .category_id(String::from("0")) + .month(ActualbudgetDate::from_i32(202401)) + .build() + .unwrap(); + + let result = map_actualbudget_zero_budget_to_budget_drips(&actualbudget_zero_budget); + + assert!(result.is_ok()); + let mut budget_drips = result.unwrap(); + assert_eq!(31, budget_drips.len()); + assert!(budget_drips.iter().all(|bd| bd.amount == 100)); + budget_drips.sort_by_key(|bd| bd.date); + for i in 1..31 { + assert_eq!( + Ok(i), + budget_drips.get(i - 1).unwrap().date.day().try_into() + ); + } + } + + #[test] + fn when_zero_budget_with_non_integer_budget_per_day_then_remainder_on_one_day() { + let actualbudget_zero_budget = make_fake_actualbudget_zero_budget_builder("0") + .amount(31 * 100 + 30) + .category_id(String::from("0")) + .month(ActualbudgetDate::from_i32(202401)) + .build() + .unwrap(); + + let result = map_actualbudget_zero_budget_to_budget_drips(&actualbudget_zero_budget); + + assert!(result.is_ok()); + let budget_drips = result.unwrap(); + assert_eq!(31, budget_drips.len()); + + let budget_drips_amount_not_floored: Vec<&Drip> = + budget_drips.iter().filter(|bd| bd.amount != 100).collect(); + assert_eq!(1, budget_drips_amount_not_floored.len()); + + let budget_drips_floored: Vec<&Drip> = + budget_drips.iter().filter(|bd| bd.amount == 100).collect(); + assert_eq!(30, budget_drips_floored.len()); + } + } + + #[test] + fn when_no_zero_budgets_then_no_budget_drips() { + let actualbudget_zero_budgets = []; + let result = transform_budget_drips(&actualbudget_zero_budgets); + assert!(result.is_ok()); + assert_eq!(result.unwrap().len(), 0); + } + + #[test] + fn when_one_zero_budget_with_integer_budget_per_day_then_evenly_divided_budget_drips() { + let actualbudget_zero_budgets = [make_fake_actualbudget_zero_budget_builder("0") + .amount(31 * 100) + .category_id(String::from("0")) + .month(ActualbudgetDate::from_i32(202401)) + .build() + .unwrap()]; + + let result = transform_budget_drips(&actualbudget_zero_budgets); + + assert!(result.is_ok()); + let mut budget_drips = result.unwrap(); + assert_eq!(31, budget_drips.len()); + assert!(budget_drips.iter().all(|bd| bd.amount == 100)); + budget_drips.sort_by_key(|bd| bd.date); + for i in 1..31 { + assert_eq!( + Ok(i), + budget_drips.get(i - 1).unwrap().date.day().try_into() + ); + } + } + + #[test] + fn when_one_zero_budgets_with_non_integer_budget_per_day_then_remainder_on_one_day() { + let actualbudget_zero_budgets = [make_fake_actualbudget_zero_budget_builder("0") + .amount(31 * 100 + 30) + .category_id(String::from("0")) + .month(ActualbudgetDate::from_i32(202401)) + .build() + .unwrap()]; + + let result = transform_budget_drips(&actualbudget_zero_budgets); + + assert!(result.is_ok()); + let budget_drips = result.unwrap(); + assert_eq!(31, budget_drips.len()); + + let budget_drips_amount_not_floored: Vec<&Drip> = + budget_drips.iter().filter(|bd| bd.amount != 100).collect(); + assert_eq!(1, budget_drips_amount_not_floored.len()); + + let budget_drips_floored: Vec<&Drip> = + budget_drips.iter().filter(|bd| bd.amount == 100).collect(); + assert_eq!(30, budget_drips_floored.len()); + } + + #[test] + fn when_multiple_zero_budgets_with_integer_budget_per_day_across_multiple_categories_then_evenly_divided_budget_drips( + ) { + let actualbudget_zero_budgets = [ + make_fake_actualbudget_zero_budget_builder("0") + .amount(31 * 100) + .category_id(String::from("0")) + .month(ActualbudgetDate::from_i32(202401)) + .build() + .unwrap(), + make_fake_actualbudget_zero_budget_builder("1") + .amount(31 * 200) + .category_id(String::from("1")) + .month(ActualbudgetDate::from_i32(202401)) + .build() + .unwrap(), + make_fake_actualbudget_zero_budget_builder("2") + .amount(29 * 110) + .category_id(String::from("0")) + .month(ActualbudgetDate::from_i32(202402)) + .build() + .unwrap(), + make_fake_actualbudget_zero_budget_builder("3") + .amount(29 * 210) + .category_id(String::from("1")) + .month(ActualbudgetDate::from_i32(202402)) + .build() + .unwrap(), + ]; + + let result = transform_budget_drips(&actualbudget_zero_budgets); + let category_0_id = transform_id("0"); + let category_1_id = transform_id("1"); + + assert!(result.is_ok()); + let budget_drips = result.unwrap(); + assert_eq!(31 + 31 + 29 + 29, budget_drips.len()); + + let budget_drips_jan_category_0: Vec<&Drip> = budget_drips + .iter() + .filter(|bd| bd.bucket_id == category_0_id && bd.date.month() == 1) + .collect(); + assert_eq!(31, budget_drips_jan_category_0.len()); + assert!(budget_drips_jan_category_0 + .iter() + .all(|bd| bd.amount == 100)); + + let budget_drips_jan_category_1: Vec<&Drip> = budget_drips + .iter() + .filter(|bd| bd.bucket_id == category_1_id && bd.date.month() == 1) + .collect(); + assert_eq!(31, budget_drips_jan_category_1.len()); + assert!(budget_drips_jan_category_1 + .iter() + .all(|bd| bd.amount == 200)); + + let budget_drips_feb_category_0: Vec<&Drip> = budget_drips + .iter() + .filter(|bd| bd.bucket_id == category_0_id && bd.date.month() == 2) + .collect(); + assert_eq!(29, budget_drips_feb_category_0.len()); + assert!(budget_drips_feb_category_0 + .iter() + .all(|bd| bd.amount == 110)); + + let budget_drips_feb_category_1: Vec<&Drip> = budget_drips + .iter() + .filter(|bd| bd.bucket_id == category_1_id && bd.date.month() == 2) + .collect(); + assert_eq!(29, budget_drips_feb_category_1.len()); + assert!(budget_drips_feb_category_1 + .iter() + .all(|bd| bd.amount == 210)); + } +} diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_zero_budgets_to_pipes.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_zero_budgets_to_pipes.rs new file mode 100644 index 0000000..2469ba9 --- /dev/null +++ b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_zero_budgets_to_pipes.rs @@ -0,0 +1,211 @@ +use actualbudget_models::ActualbudgetZeroBudget; +use anyhow::Context; +use itertools::Itertools; +use schist_models::{BudgetPeriodUnit, Pipe}; + +use super::{transform_date, transform_id}; + +pub fn transform_zero_budgets_to_pipes( + actualbudget_zero_budgets: &[ActualbudgetZeroBudget], +) -> anyhow::Result> { + let pipes_with_consecutive_duplicates = actualbudget_zero_budgets + .iter() + .filter(|zb| zb.amount != 0) + .map(transform_zero_budget_to_pipe) + .collect::>>() + .context("Failed to transform Actualbudget zero budgets to Schist pipes")?; + + Ok(pipes_with_consecutive_duplicates + .into_iter() + .sorted_by_key(|pipe| pipe.start_date) + .sorted_by_key(|pipe| pipe.bucket_id) + .fold(Vec::new(), fold_pipes)) +} + +fn transform_zero_budget_to_pipe( + actualbudget_zero_budget: &ActualbudgetZeroBudget, +) -> anyhow::Result { + Ok(Pipe { + id: transform_id(&actualbudget_zero_budget.id), + amount: actualbudget_zero_budget.amount, + bucket_id: transform_id(&actualbudget_zero_budget.category_id), + period_length: 1, + period_unit: BudgetPeriodUnit::Month, + start_date: transform_date(&actualbudget_zero_budget.month)?, + }) +} + +fn fold_pipes(mut pipes: Vec, pipe: Pipe) -> Vec { + if let Some(index) = pipes.iter().position(|other_pipe| { + other_pipe.bucket_id == pipe.bucket_id && other_pipe.amount == pipe.amount + }) { + if pipe.start_date < pipes[index].start_date { + pipes.remove(index); + pipes.push(pipe); + } + } else { + pipes.push(pipe); + } + pipes +} + +#[cfg(test)] +mod test { + use actualbudget_models::{ActualbudgetDate, ActualbudgetZeroBudget}; + use schist_models::{BudgetPeriodUnit, Pipe}; + + use crate::transform_state::transform_id::transform_id; + + #[test] + fn when_zero_budget_is_zero_then_does_not_transform() { + let actualbudget_zero_budgets = vec![ActualbudgetZeroBudget { + id: String::from("zb-0"), + month: ActualbudgetDate::from_i32(202408), + category_id: String::from("category-0"), + amount: 0, + do_carry_over: true, + }]; + + let pipes = super::transform_zero_budgets_to_pipes(&actualbudget_zero_budgets); + + assert!(pipes.is_ok()); + assert_eq!(0, pipes.unwrap().len()); + } + + #[test] + fn when_zero_budget_is_not_zero_then_does_transform() { + let actualbudget_zero_budgets = vec![ + ActualbudgetZeroBudget { + id: String::from("zb-0"), + month: ActualbudgetDate::from_i32(202408), + category_id: String::from("category-0"), + amount: 100, + do_carry_over: true, + }, + ActualbudgetZeroBudget { + id: String::from("zb-1"), + month: ActualbudgetDate::from_i32(202408), + category_id: String::from("category-1"), + amount: -200, + do_carry_over: false, + }, + ]; + + let pipes = super::transform_zero_budgets_to_pipes(&actualbudget_zero_budgets); + + assert!(pipes.is_ok()); + let mut pipes = pipes.unwrap(); + pipes.sort_by_key(|pipe| -pipe.amount); + assert_eq!( + vec![ + Pipe { + id: transform_id("zb-0"), + amount: 100, + bucket_id: transform_id("category-0"), + period_length: 1, + period_unit: BudgetPeriodUnit::Month, + start_date: "2024-08-01".parse().unwrap(), + }, + Pipe { + id: transform_id("zb-1"), + amount: -200, + bucket_id: transform_id("category-1"), + period_length: 1, + period_unit: BudgetPeriodUnit::Month, + start_date: "2024-08-01".parse().unwrap() + }, + ], + pipes, + ); + } + + #[test] + fn when_zero_budgets_contain_adjacent_duplicates_then_merge() { + let actualbudget_zero_budgets = vec![ + ActualbudgetZeroBudget { + id: String::from("zb-0"), + month: ActualbudgetDate::from_i32(202408), + category_id: String::from("category-0"), + amount: 100, + do_carry_over: true, + }, + ActualbudgetZeroBudget { + id: String::from("zb-1"), + month: ActualbudgetDate::from_i32(202409), + category_id: String::from("category-1"), + amount: -200, + do_carry_over: false, + }, + ActualbudgetZeroBudget { + id: String::from("zb-0"), + month: ActualbudgetDate::from_i32(202410), + category_id: String::from("category-0"), + amount: 100, + do_carry_over: true, + }, + ActualbudgetZeroBudget { + id: String::from("zb-0"), + month: ActualbudgetDate::from_i32(202411), + category_id: String::from("category-0"), + amount: 200, + do_carry_over: false, + }, + ActualbudgetZeroBudget { + id: String::from("zb-1"), + month: ActualbudgetDate::from_i32(202410), + category_id: String::from("category-1"), + amount: -200, + do_carry_over: true, + }, + ActualbudgetZeroBudget { + id: String::from("zb-0"), + month: ActualbudgetDate::from_i32(202409), + category_id: String::from("category-0"), + amount: 100, + do_carry_over: false, + }, + ActualbudgetZeroBudget { + id: String::from("zb-1"), + month: ActualbudgetDate::from_i32(202408), + category_id: String::from("category-1"), + amount: -200, + do_carry_over: true, + }, + ]; + + let pipes = super::transform_zero_budgets_to_pipes(&actualbudget_zero_budgets); + + assert!(pipes.is_ok()); + let mut pipes = pipes.unwrap(); + pipes.sort_by_key(|pipe| -pipe.amount); + assert_eq!( + vec![ + Pipe { + id: transform_id("zb-0"), + amount: 200, + bucket_id: transform_id("category-0"), + period_length: 1, + period_unit: BudgetPeriodUnit::Month, + start_date: "2024-11-01".parse().unwrap(), + }, + Pipe { + id: transform_id("zb-0"), + amount: 100, + bucket_id: transform_id("category-0"), + period_length: 1, + period_unit: BudgetPeriodUnit::Month, + start_date: "2024-08-01".parse().unwrap(), + }, + Pipe { + id: transform_id("zb-1"), + amount: -200, + bucket_id: transform_id("category-1"), + period_length: 1, + period_unit: BudgetPeriodUnit::Month, + start_date: "2024-08-01".parse().unwrap() + }, + ], + pipes, + ); + } +} diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/account_matchers.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/account_matchers.rs index eaa1805..358d894 100644 --- a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/account_matchers.rs +++ b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/account_matchers.rs @@ -10,10 +10,10 @@ pub fn get_account_matchers() -> Vec bool>> { fn matches_bank_account(account: &Account) -> bool { match account { Account { + id: _id, name, - opening_balance: 0, + opening_balance: 1200_00, opening_date, - .. } => name == "My bank account" && *opening_date == "2024-08-01".parse().unwrap(), _ => false, } @@ -22,10 +22,10 @@ fn matches_bank_account(account: &Account) -> bool { fn matches_cash_account(account: &Account) -> bool { match account { Account { + id: _id, name, - opening_balance: 0, + opening_balance: 45_10, opening_date, - .. } => name == "My cash account" && *opening_date == "2024-08-01".parse().unwrap(), _ => false, } diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/bucket_matchers.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/bucket_matchers.rs index 3821806..5753847 100644 --- a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/bucket_matchers.rs +++ b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/bucket_matchers.rs @@ -1,16 +1,27 @@ use schist_models::Bucket; -use super::bucket_names::BUCKET_NAMES; +use super::bucket_names::BUCKET_NAMES_AND_GROUP_NAMES; pub fn get_bucket_matchers() -> Vec bool>> { - BUCKET_NAMES + BUCKET_NAMES_AND_GROUP_NAMES .iter() - .map(|bucket_name| -> Box bool> { - Box::new(|c| matches_bucket(c, bucket_name)) - }) + .map( + |bucket_name_and_group_name| -> Box bool> { + Box::new(|c| matches_bucket(c, bucket_name_and_group_name)) + }, + ) .collect() } -fn matches_bucket(bucket: &Bucket, bucket_name: &str) -> bool { - bucket.name == bucket_name && bucket.balance == None && bucket.balance_cache_key == None +fn matches_bucket(bucket: &Bucket, (bucket_name, group_name): &(&str, &str)) -> bool { + match bucket { + Bucket { + id: _id, + balance: None, + balance_cache_key: None, + group, + name, + } => *name == *bucket_name && *group == *group_name, + _ => false, + } } diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/bucket_names.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/bucket_names.rs index 5f232a8..d8b5d3f 100644 --- a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/bucket_names.rs +++ b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/bucket_names.rs @@ -3,17 +3,19 @@ pub const BUCKET_NAME_GENERAL: &str = "General"; pub const BUCKET_NAME_BILLS: &str = "Bills"; pub const BUCKET_NAME_BILLS_FLEXIBLE: &str = "Bills (Flexible)"; pub const BUCKET_NAME_SAVINGS: &str = "Savings"; -pub const BUCKET_NAME_STARTING_BALANCES: &str = "Starting Balances"; pub const BUCKET_NAME_INCOME: &str = "Income"; -pub const BUCKET_NAMES: &[&'static str] = &[ - BUCKET_NAME_FOOD, - BUCKET_NAME_GENERAL, - BUCKET_NAME_BILLS, - BUCKET_NAME_BILLS_FLEXIBLE, - BUCKET_NAME_SAVINGS, - BUCKET_NAME_STARTING_BALANCES, - BUCKET_NAME_INCOME, +pub const GROUP_NAME_USUAL_EXPENSES: &str = "Usual Expenses"; +pub const GROUP_NAME_SAVINGS: &str = "Investments and Savings"; +pub const GROUP_NAME_INCOME: &str = "Income"; + +pub const BUCKET_NAMES_AND_GROUP_NAMES: &[(&'static str, &'static str)] = &[ + (BUCKET_NAME_FOOD, GROUP_NAME_USUAL_EXPENSES), + (BUCKET_NAME_GENERAL, GROUP_NAME_USUAL_EXPENSES), + (BUCKET_NAME_BILLS, GROUP_NAME_USUAL_EXPENSES), + (BUCKET_NAME_BILLS_FLEXIBLE, GROUP_NAME_USUAL_EXPENSES), + (BUCKET_NAME_SAVINGS, GROUP_NAME_SAVINGS), + (BUCKET_NAME_INCOME, GROUP_NAME_INCOME), ]; pub struct BucketNamesDict { @@ -22,7 +24,6 @@ pub struct BucketNamesDict { pub bills: &'static str, pub bills_flexible: &'static str, pub savings: &'static str, - pub starting_balances: &'static str, pub income: &'static str, } @@ -32,6 +33,5 @@ pub const BUCKET_NAMES_DICT: BucketNamesDict = BucketNamesDict { bills: BUCKET_NAME_BILLS, bills_flexible: BUCKET_NAME_BILLS_FLEXIBLE, savings: BUCKET_NAME_SAVINGS, - starting_balances: BUCKET_NAME_STARTING_BALANCES, income: BUCKET_NAME_INCOME, }; diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/budget_drip_matchers.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/budget_drip_matchers.rs deleted file mode 100644 index 85ad045..0000000 --- a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/budget_drip_matchers.rs +++ /dev/null @@ -1,211 +0,0 @@ -use std::sync::LazyLock; - -use schist_models::{Bucket, DateUtc, Drip}; -use schist_traits::Dateable; - -use crate::common::bucket_names::{ - BUCKET_NAME_BILLS, BUCKET_NAME_BILLS_FLEXIBLE, BUCKET_NAME_FOOD, BUCKET_NAME_GENERAL, - BUCKET_NAME_SAVINGS, -}; - -pub fn get_num_expected_budget_drips() -> usize { - let num_expected_dates = DATES.len(); - let num_expected_buckets = 5; - num_expected_dates * num_expected_buckets -} - -pub fn assert_budget_drip_matches(budget_drip: &Drip, buckets: &[Bucket]) { - assert!( - DATES - .iter() - .any(|date| *LazyLock::force(date) == budget_drip.date), - "budget drip had unexpected date: {:?}", - budget_drip, - ); - - let bucket_name = buckets - .iter() - .find(|c| c.id == budget_drip.bucket_id) - .map(|c| c.name.clone()) - .expect( - format!( - "budget drip {:?} had unexpected bucket ID. buckets were: {:?}", - budget_drip, buckets, - ) - .as_str(), - ); - - let expected_quantity = match ( - bucket_name.as_str(), - budget_drip.date.month(), - budget_drip.date.day(), - ) { - (BUCKET_NAME_FOOD, 8 | 10, 1) => 6_50, - (BUCKET_NAME_FOOD, 8 | 10, _) => 6_45, - (BUCKET_NAME_FOOD, 9 | 11, 1) => 6_86, - (BUCKET_NAME_FOOD, 9 | 11, _) => 6_66, - (BUCKET_NAME_FOOD, _, _) => panic!("budget drip {:?} had unexpected date", budget_drip), - - (BUCKET_NAME_GENERAL, 8 | 10, 1) => 3_40, - (BUCKET_NAME_GENERAL, 8 | 10, _) => 3_22, - (BUCKET_NAME_GENERAL, 9 | 11, 1) => 3_43, - (BUCKET_NAME_GENERAL, 9 | 11, _) => 3_33, - (BUCKET_NAME_GENERAL, _, _) => panic!("budget drip {:?} had unexpected date", budget_drip), - - (BUCKET_NAME_BILLS, 8 | 10, 1) => 4_10, - (BUCKET_NAME_BILLS, 8 | 10, _) => 4_03, - (BUCKET_NAME_BILLS, 9 | 11, 1) => 4_36, - (BUCKET_NAME_BILLS, 9 | 11, _) => 4_16, - (BUCKET_NAME_BILLS, _, _) => panic!("budget drip {:?} had unexpected date", budget_drip), - - (BUCKET_NAME_BILLS_FLEXIBLE, 8 | 10, 1) => 1_00, - (BUCKET_NAME_BILLS_FLEXIBLE, 8 | 10, _) => 80, - (BUCKET_NAME_BILLS_FLEXIBLE, 9 | 11, 1) => 93, - (BUCKET_NAME_BILLS_FLEXIBLE, 9 | 11, _) => 83, - (BUCKET_NAME_BILLS_FLEXIBLE, _, _) => { - panic!("budget drip {:?} had unexpected date", budget_drip) - } - - (BUCKET_NAME_SAVINGS, 8 | 10, 1) => 13_00, - (BUCKET_NAME_SAVINGS, 8 | 10, _) => 12_90, - (BUCKET_NAME_SAVINGS, 9 | 11, 1) => 13_43, - (BUCKET_NAME_SAVINGS, 9 | 11, _) => 13_33, - (BUCKET_NAME_SAVINGS, _, _) => panic!("budget drip {:?} had unexpected date", budget_drip), - - (_, _, _) => panic!( - "budget drip {:?} had unexpected bucket name {}", - budget_drip, bucket_name, - ), - }; - - assert_eq!( - expected_quantity, budget_drip.amount, - "budget drip {:?} had unexpected quantity", - budget_drip, - ); -} - -static DATES: [LazyLock; 122] = [ - LazyLock::::new(|| "2024-08-01".parse().unwrap()), - LazyLock::::new(|| "2024-08-02".parse().unwrap()), - LazyLock::::new(|| "2024-08-03".parse().unwrap()), - LazyLock::::new(|| "2024-08-04".parse().unwrap()), - LazyLock::::new(|| "2024-08-05".parse().unwrap()), - LazyLock::::new(|| "2024-08-06".parse().unwrap()), - LazyLock::::new(|| "2024-08-07".parse().unwrap()), - LazyLock::::new(|| "2024-08-08".parse().unwrap()), - LazyLock::::new(|| "2024-08-09".parse().unwrap()), - LazyLock::::new(|| "2024-08-10".parse().unwrap()), - LazyLock::::new(|| "2024-08-11".parse().unwrap()), - LazyLock::::new(|| "2024-08-12".parse().unwrap()), - LazyLock::::new(|| "2024-08-13".parse().unwrap()), - LazyLock::::new(|| "2024-08-14".parse().unwrap()), - LazyLock::::new(|| "2024-08-15".parse().unwrap()), - LazyLock::::new(|| "2024-08-16".parse().unwrap()), - LazyLock::::new(|| "2024-08-17".parse().unwrap()), - LazyLock::::new(|| "2024-08-18".parse().unwrap()), - LazyLock::::new(|| "2024-08-19".parse().unwrap()), - LazyLock::::new(|| "2024-08-20".parse().unwrap()), - LazyLock::::new(|| "2024-08-21".parse().unwrap()), - LazyLock::::new(|| "2024-08-22".parse().unwrap()), - LazyLock::::new(|| "2024-08-23".parse().unwrap()), - LazyLock::::new(|| "2024-08-24".parse().unwrap()), - LazyLock::::new(|| "2024-08-25".parse().unwrap()), - LazyLock::::new(|| "2024-08-26".parse().unwrap()), - LazyLock::::new(|| "2024-08-27".parse().unwrap()), - LazyLock::::new(|| "2024-08-28".parse().unwrap()), - LazyLock::::new(|| "2024-08-29".parse().unwrap()), - LazyLock::::new(|| "2024-08-30".parse().unwrap()), - LazyLock::::new(|| "2024-08-31".parse().unwrap()), - LazyLock::::new(|| "2024-09-01".parse().unwrap()), - LazyLock::::new(|| "2024-09-02".parse().unwrap()), - LazyLock::::new(|| "2024-09-03".parse().unwrap()), - LazyLock::::new(|| "2024-09-04".parse().unwrap()), - LazyLock::::new(|| "2024-09-05".parse().unwrap()), - LazyLock::::new(|| "2024-09-06".parse().unwrap()), - LazyLock::::new(|| "2024-09-07".parse().unwrap()), - LazyLock::::new(|| "2024-09-08".parse().unwrap()), - LazyLock::::new(|| "2024-09-09".parse().unwrap()), - LazyLock::::new(|| "2024-09-10".parse().unwrap()), - LazyLock::::new(|| "2024-09-11".parse().unwrap()), - LazyLock::::new(|| "2024-09-12".parse().unwrap()), - LazyLock::::new(|| "2024-09-13".parse().unwrap()), - LazyLock::::new(|| "2024-09-14".parse().unwrap()), - LazyLock::::new(|| "2024-09-15".parse().unwrap()), - LazyLock::::new(|| "2024-09-16".parse().unwrap()), - LazyLock::::new(|| "2024-09-17".parse().unwrap()), - LazyLock::::new(|| "2024-09-18".parse().unwrap()), - LazyLock::::new(|| "2024-09-19".parse().unwrap()), - LazyLock::::new(|| "2024-09-20".parse().unwrap()), - LazyLock::::new(|| "2024-09-21".parse().unwrap()), - LazyLock::::new(|| "2024-09-22".parse().unwrap()), - LazyLock::::new(|| "2024-09-23".parse().unwrap()), - LazyLock::::new(|| "2024-09-24".parse().unwrap()), - LazyLock::::new(|| "2024-09-25".parse().unwrap()), - LazyLock::::new(|| "2024-09-26".parse().unwrap()), - LazyLock::::new(|| "2024-09-27".parse().unwrap()), - LazyLock::::new(|| "2024-09-28".parse().unwrap()), - LazyLock::::new(|| "2024-09-29".parse().unwrap()), - LazyLock::::new(|| "2024-09-30".parse().unwrap()), - LazyLock::::new(|| "2024-10-01".parse().unwrap()), - LazyLock::::new(|| "2024-10-02".parse().unwrap()), - LazyLock::::new(|| "2024-10-03".parse().unwrap()), - LazyLock::::new(|| "2024-10-04".parse().unwrap()), - LazyLock::::new(|| "2024-10-05".parse().unwrap()), - LazyLock::::new(|| "2024-10-06".parse().unwrap()), - LazyLock::::new(|| "2024-10-07".parse().unwrap()), - LazyLock::::new(|| "2024-10-08".parse().unwrap()), - LazyLock::::new(|| "2024-10-09".parse().unwrap()), - LazyLock::::new(|| "2024-10-10".parse().unwrap()), - LazyLock::::new(|| "2024-10-11".parse().unwrap()), - LazyLock::::new(|| "2024-10-12".parse().unwrap()), - LazyLock::::new(|| "2024-10-13".parse().unwrap()), - LazyLock::::new(|| "2024-10-14".parse().unwrap()), - LazyLock::::new(|| "2024-10-15".parse().unwrap()), - LazyLock::::new(|| "2024-10-16".parse().unwrap()), - LazyLock::::new(|| "2024-10-17".parse().unwrap()), - LazyLock::::new(|| "2024-10-18".parse().unwrap()), - LazyLock::::new(|| "2024-10-19".parse().unwrap()), - LazyLock::::new(|| "2024-10-20".parse().unwrap()), - LazyLock::::new(|| "2024-10-21".parse().unwrap()), - LazyLock::::new(|| "2024-10-22".parse().unwrap()), - LazyLock::::new(|| "2024-10-23".parse().unwrap()), - LazyLock::::new(|| "2024-10-24".parse().unwrap()), - LazyLock::::new(|| "2024-10-25".parse().unwrap()), - LazyLock::::new(|| "2024-10-26".parse().unwrap()), - LazyLock::::new(|| "2024-10-27".parse().unwrap()), - LazyLock::::new(|| "2024-10-28".parse().unwrap()), - LazyLock::::new(|| "2024-10-29".parse().unwrap()), - LazyLock::::new(|| "2024-10-30".parse().unwrap()), - LazyLock::::new(|| "2024-10-31".parse().unwrap()), - LazyLock::::new(|| "2024-11-01".parse().unwrap()), - LazyLock::::new(|| "2024-11-02".parse().unwrap()), - LazyLock::::new(|| "2024-11-03".parse().unwrap()), - LazyLock::::new(|| "2024-11-04".parse().unwrap()), - LazyLock::::new(|| "2024-11-05".parse().unwrap()), - LazyLock::::new(|| "2024-11-06".parse().unwrap()), - LazyLock::::new(|| "2024-11-07".parse().unwrap()), - LazyLock::::new(|| "2024-11-08".parse().unwrap()), - LazyLock::::new(|| "2024-11-09".parse().unwrap()), - LazyLock::::new(|| "2024-11-10".parse().unwrap()), - LazyLock::::new(|| "2024-11-11".parse().unwrap()), - LazyLock::::new(|| "2024-11-12".parse().unwrap()), - LazyLock::::new(|| "2024-11-13".parse().unwrap()), - LazyLock::::new(|| "2024-11-14".parse().unwrap()), - LazyLock::::new(|| "2024-11-15".parse().unwrap()), - LazyLock::::new(|| "2024-11-16".parse().unwrap()), - LazyLock::::new(|| "2024-11-17".parse().unwrap()), - LazyLock::::new(|| "2024-11-18".parse().unwrap()), - LazyLock::::new(|| "2024-11-19".parse().unwrap()), - LazyLock::::new(|| "2024-11-20".parse().unwrap()), - LazyLock::::new(|| "2024-11-21".parse().unwrap()), - LazyLock::::new(|| "2024-11-22".parse().unwrap()), - LazyLock::::new(|| "2024-11-23".parse().unwrap()), - LazyLock::::new(|| "2024-11-24".parse().unwrap()), - LazyLock::::new(|| "2024-11-25".parse().unwrap()), - LazyLock::::new(|| "2024-11-26".parse().unwrap()), - LazyLock::::new(|| "2024-11-27".parse().unwrap()), - LazyLock::::new(|| "2024-11-28".parse().unwrap()), - LazyLock::::new(|| "2024-11-29".parse().unwrap()), - LazyLock::::new(|| "2024-11-30".parse().unwrap()), -]; diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/drip_matchers.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/drip_matchers.rs new file mode 100644 index 0000000..629a04b --- /dev/null +++ b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/drip_matchers.rs @@ -0,0 +1,202 @@ +use std::sync::LazyLock; + +use schist_models::{Bucket, DateUtc, Drip}; +use schist_traits::Dateable; + +use crate::common::bucket_names::{ + BUCKET_NAME_BILLS, BUCKET_NAME_BILLS_FLEXIBLE, BUCKET_NAME_FOOD, BUCKET_NAME_GENERAL, + BUCKET_NAME_SAVINGS, +}; + +pub fn get_num_expected_drips() -> usize { + let num_expected_dates = DATES.len(); + let num_expected_buckets = 5; + num_expected_dates * num_expected_buckets +} + +pub fn assert_drip_matches(drip: &Drip, buckets: &[Bucket]) { + assert!( + DATES.iter().any(|date| *LazyLock::force(date) == drip.date), + "drip had unexpected date: {:?}", + drip, + ); + + let bucket_name = buckets + .iter() + .find(|c| c.id == drip.bucket_id) + .map(|c| c.name.clone()) + .expect( + format!( + "drip {:?} had unexpected bucket ID. buckets were: {:?}", + drip, buckets, + ) + .as_str(), + ); + + let expected_quantity = match (bucket_name.as_str(), drip.date.month(), drip.date.day()) { + (BUCKET_NAME_FOOD, 8 | 10, 1) => 6_50, + (BUCKET_NAME_FOOD, 8 | 10, _) => 6_45, + (BUCKET_NAME_FOOD, 9 | 11, 1) => 6_86, + (BUCKET_NAME_FOOD, 9 | 11, _) => 6_66, + (BUCKET_NAME_FOOD, _, _) => panic!("drip {:?} had unexpected date", drip), + + (BUCKET_NAME_GENERAL, 8 | 10, 1) => 3_40, + (BUCKET_NAME_GENERAL, 8 | 10, _) => 3_22, + (BUCKET_NAME_GENERAL, 9 | 11, 1) => 3_43, + (BUCKET_NAME_GENERAL, 9 | 11, _) => 3_33, + (BUCKET_NAME_GENERAL, _, _) => panic!("drip {:?} had unexpected date", drip), + + (BUCKET_NAME_BILLS, 8 | 10, 1) => 4_10, + (BUCKET_NAME_BILLS, 8 | 10, _) => 4_03, + (BUCKET_NAME_BILLS, 9 | 11, 1) => 4_36, + (BUCKET_NAME_BILLS, 9 | 11, _) => 4_16, + (BUCKET_NAME_BILLS, _, _) => panic!("drip {:?} had unexpected date", drip), + + (BUCKET_NAME_BILLS_FLEXIBLE, 8 | 10, 1) => 1_00, + (BUCKET_NAME_BILLS_FLEXIBLE, 8 | 10, _) => 80, + (BUCKET_NAME_BILLS_FLEXIBLE, 9 | 11, 1) => 93, + (BUCKET_NAME_BILLS_FLEXIBLE, 9 | 11, _) => 83, + (BUCKET_NAME_BILLS_FLEXIBLE, _, _) => { + panic!("drip {:?} had unexpected date", drip) + } + + (BUCKET_NAME_SAVINGS, 8 | 10, 1) => 13_00, + (BUCKET_NAME_SAVINGS, 8 | 10, _) => 12_90, + (BUCKET_NAME_SAVINGS, 9 | 11, 1) => 13_43, + (BUCKET_NAME_SAVINGS, 9 | 11, _) => 13_33, + (BUCKET_NAME_SAVINGS, _, _) => panic!("drip {:?} had unexpected date", drip), + + (_, _, _) => panic!("drip {:?} had unexpected bucket name {}", drip, bucket_name,), + }; + + assert_eq!( + expected_quantity, drip.amount, + "drip {:?} had unexpected quantity", + drip, + ); +} + +static DATES: [LazyLock; 122] = [ + LazyLock::::new(|| "2024-08-01".parse().unwrap()), + LazyLock::::new(|| "2024-08-02".parse().unwrap()), + LazyLock::::new(|| "2024-08-03".parse().unwrap()), + LazyLock::::new(|| "2024-08-04".parse().unwrap()), + LazyLock::::new(|| "2024-08-05".parse().unwrap()), + LazyLock::::new(|| "2024-08-06".parse().unwrap()), + LazyLock::::new(|| "2024-08-07".parse().unwrap()), + LazyLock::::new(|| "2024-08-08".parse().unwrap()), + LazyLock::::new(|| "2024-08-09".parse().unwrap()), + LazyLock::::new(|| "2024-08-10".parse().unwrap()), + LazyLock::::new(|| "2024-08-11".parse().unwrap()), + LazyLock::::new(|| "2024-08-12".parse().unwrap()), + LazyLock::::new(|| "2024-08-13".parse().unwrap()), + LazyLock::::new(|| "2024-08-14".parse().unwrap()), + LazyLock::::new(|| "2024-08-15".parse().unwrap()), + LazyLock::::new(|| "2024-08-16".parse().unwrap()), + LazyLock::::new(|| "2024-08-17".parse().unwrap()), + LazyLock::::new(|| "2024-08-18".parse().unwrap()), + LazyLock::::new(|| "2024-08-19".parse().unwrap()), + LazyLock::::new(|| "2024-08-20".parse().unwrap()), + LazyLock::::new(|| "2024-08-21".parse().unwrap()), + LazyLock::::new(|| "2024-08-22".parse().unwrap()), + LazyLock::::new(|| "2024-08-23".parse().unwrap()), + LazyLock::::new(|| "2024-08-24".parse().unwrap()), + LazyLock::::new(|| "2024-08-25".parse().unwrap()), + LazyLock::::new(|| "2024-08-26".parse().unwrap()), + LazyLock::::new(|| "2024-08-27".parse().unwrap()), + LazyLock::::new(|| "2024-08-28".parse().unwrap()), + LazyLock::::new(|| "2024-08-29".parse().unwrap()), + LazyLock::::new(|| "2024-08-30".parse().unwrap()), + LazyLock::::new(|| "2024-08-31".parse().unwrap()), + LazyLock::::new(|| "2024-09-01".parse().unwrap()), + LazyLock::::new(|| "2024-09-02".parse().unwrap()), + LazyLock::::new(|| "2024-09-03".parse().unwrap()), + LazyLock::::new(|| "2024-09-04".parse().unwrap()), + LazyLock::::new(|| "2024-09-05".parse().unwrap()), + LazyLock::::new(|| "2024-09-06".parse().unwrap()), + LazyLock::::new(|| "2024-09-07".parse().unwrap()), + LazyLock::::new(|| "2024-09-08".parse().unwrap()), + LazyLock::::new(|| "2024-09-09".parse().unwrap()), + LazyLock::::new(|| "2024-09-10".parse().unwrap()), + LazyLock::::new(|| "2024-09-11".parse().unwrap()), + LazyLock::::new(|| "2024-09-12".parse().unwrap()), + LazyLock::::new(|| "2024-09-13".parse().unwrap()), + LazyLock::::new(|| "2024-09-14".parse().unwrap()), + LazyLock::::new(|| "2024-09-15".parse().unwrap()), + LazyLock::::new(|| "2024-09-16".parse().unwrap()), + LazyLock::::new(|| "2024-09-17".parse().unwrap()), + LazyLock::::new(|| "2024-09-18".parse().unwrap()), + LazyLock::::new(|| "2024-09-19".parse().unwrap()), + LazyLock::::new(|| "2024-09-20".parse().unwrap()), + LazyLock::::new(|| "2024-09-21".parse().unwrap()), + LazyLock::::new(|| "2024-09-22".parse().unwrap()), + LazyLock::::new(|| "2024-09-23".parse().unwrap()), + LazyLock::::new(|| "2024-09-24".parse().unwrap()), + LazyLock::::new(|| "2024-09-25".parse().unwrap()), + LazyLock::::new(|| "2024-09-26".parse().unwrap()), + LazyLock::::new(|| "2024-09-27".parse().unwrap()), + LazyLock::::new(|| "2024-09-28".parse().unwrap()), + LazyLock::::new(|| "2024-09-29".parse().unwrap()), + LazyLock::::new(|| "2024-09-30".parse().unwrap()), + LazyLock::::new(|| "2024-10-01".parse().unwrap()), + LazyLock::::new(|| "2024-10-02".parse().unwrap()), + LazyLock::::new(|| "2024-10-03".parse().unwrap()), + LazyLock::::new(|| "2024-10-04".parse().unwrap()), + LazyLock::::new(|| "2024-10-05".parse().unwrap()), + LazyLock::::new(|| "2024-10-06".parse().unwrap()), + LazyLock::::new(|| "2024-10-07".parse().unwrap()), + LazyLock::::new(|| "2024-10-08".parse().unwrap()), + LazyLock::::new(|| "2024-10-09".parse().unwrap()), + LazyLock::::new(|| "2024-10-10".parse().unwrap()), + LazyLock::::new(|| "2024-10-11".parse().unwrap()), + LazyLock::::new(|| "2024-10-12".parse().unwrap()), + LazyLock::::new(|| "2024-10-13".parse().unwrap()), + LazyLock::::new(|| "2024-10-14".parse().unwrap()), + LazyLock::::new(|| "2024-10-15".parse().unwrap()), + LazyLock::::new(|| "2024-10-16".parse().unwrap()), + LazyLock::::new(|| "2024-10-17".parse().unwrap()), + LazyLock::::new(|| "2024-10-18".parse().unwrap()), + LazyLock::::new(|| "2024-10-19".parse().unwrap()), + LazyLock::::new(|| "2024-10-20".parse().unwrap()), + LazyLock::::new(|| "2024-10-21".parse().unwrap()), + LazyLock::::new(|| "2024-10-22".parse().unwrap()), + LazyLock::::new(|| "2024-10-23".parse().unwrap()), + LazyLock::::new(|| "2024-10-24".parse().unwrap()), + LazyLock::::new(|| "2024-10-25".parse().unwrap()), + LazyLock::::new(|| "2024-10-26".parse().unwrap()), + LazyLock::::new(|| "2024-10-27".parse().unwrap()), + LazyLock::::new(|| "2024-10-28".parse().unwrap()), + LazyLock::::new(|| "2024-10-29".parse().unwrap()), + LazyLock::::new(|| "2024-10-30".parse().unwrap()), + LazyLock::::new(|| "2024-10-31".parse().unwrap()), + LazyLock::::new(|| "2024-11-01".parse().unwrap()), + LazyLock::::new(|| "2024-11-02".parse().unwrap()), + LazyLock::::new(|| "2024-11-03".parse().unwrap()), + LazyLock::::new(|| "2024-11-04".parse().unwrap()), + LazyLock::::new(|| "2024-11-05".parse().unwrap()), + LazyLock::::new(|| "2024-11-06".parse().unwrap()), + LazyLock::::new(|| "2024-11-07".parse().unwrap()), + LazyLock::::new(|| "2024-11-08".parse().unwrap()), + LazyLock::::new(|| "2024-11-09".parse().unwrap()), + LazyLock::::new(|| "2024-11-10".parse().unwrap()), + LazyLock::::new(|| "2024-11-11".parse().unwrap()), + LazyLock::::new(|| "2024-11-12".parse().unwrap()), + LazyLock::::new(|| "2024-11-13".parse().unwrap()), + LazyLock::::new(|| "2024-11-14".parse().unwrap()), + LazyLock::::new(|| "2024-11-15".parse().unwrap()), + LazyLock::::new(|| "2024-11-16".parse().unwrap()), + LazyLock::::new(|| "2024-11-17".parse().unwrap()), + LazyLock::::new(|| "2024-11-18".parse().unwrap()), + LazyLock::::new(|| "2024-11-19".parse().unwrap()), + LazyLock::::new(|| "2024-11-20".parse().unwrap()), + LazyLock::::new(|| "2024-11-21".parse().unwrap()), + LazyLock::::new(|| "2024-11-22".parse().unwrap()), + LazyLock::::new(|| "2024-11-23".parse().unwrap()), + LazyLock::::new(|| "2024-11-24".parse().unwrap()), + LazyLock::::new(|| "2024-11-25".parse().unwrap()), + LazyLock::::new(|| "2024-11-26".parse().unwrap()), + LazyLock::::new(|| "2024-11-27".parse().unwrap()), + LazyLock::::new(|| "2024-11-28".parse().unwrap()), + LazyLock::::new(|| "2024-11-29".parse().unwrap()), + LazyLock::::new(|| "2024-11-30".parse().unwrap()), +]; diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/mod.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/mod.rs index f067d0d..82d7870 100644 --- a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/mod.rs +++ b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/mod.rs @@ -4,8 +4,9 @@ mod account_transfer_matchers; mod bucket_matchers; mod bucket_names; mod bucket_transfer_matchers; -mod budget_drip_matchers; mod db_url; +mod drip_matchers; +mod pipe_matchers; mod test_context; mod transaction_matchers; @@ -15,13 +16,12 @@ pub use account_transfer_matchers::get_account_transfer_matchers; pub use bucket_matchers::get_bucket_matchers; pub use bucket_names::BUCKET_NAMES_DICT; pub use bucket_transfer_matchers::get_bucket_transfer_matchers; -pub use budget_drip_matchers::assert_budget_drip_matches; -pub use budget_drip_matchers::get_num_expected_budget_drips; pub use db_url::TEST_IN_DB_URL; pub use db_url::TEST_OUT_DB_URL; +pub use drip_matchers::assert_drip_matches; +pub use drip_matchers::get_num_expected_drips; +pub use pipe_matchers::get_pipe_matchers; pub use test_context::TestContext; pub use transaction_matchers::get_transaction_matchers; -pub use transaction_matchers::matches_bank_account_initial_transaction; -pub use transaction_matchers::matches_cash_account_initial_transaction; pub use transaction_matchers::matches_food_transaction; pub use transaction_matchers::matches_income_transaction; diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/pipe_matchers.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/pipe_matchers.rs new file mode 100644 index 0000000..3ca92c8 --- /dev/null +++ b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/pipe_matchers.rs @@ -0,0 +1,36 @@ +use schist_models::{Bucket, BudgetPeriodUnit, Pipe}; + +use super::bucket_names::BUCKET_NAMES_DICT; + +macro_rules! matches_pipe { + ($pipe:ident, $amount:expr, $start_date:expr) => { + Box::new(|pipe: &Pipe, buckets: &[Bucket]| -> bool { + let bucket = buckets + .iter() + .find(|c| c.name == BUCKET_NAMES_DICT.$pipe) + .expect("failed to find $pipe bucket"); + + match pipe { + Pipe { + id: _id, + amount: $amount, + bucket_id, + period_length: 1, + period_unit: BudgetPeriodUnit::Month, + start_date, + } => *bucket_id == bucket.id && *start_date == $start_date.parse().unwrap(), + _ => false, + } + }) + }; +} + +pub fn get_pipe_matchers() -> Vec bool>> { + vec![ + matches_pipe!(food, 200_00, "2024-08-01"), + matches_pipe!(general, 100_00, "2024-08-01"), + matches_pipe!(bills, 125_00, "2024-08-01"), + matches_pipe!(bills_flexible, 25_00, "2024-08-01"), + matches_pipe!(savings, 400_00, "2024-08-01"), + ] +} diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/transaction_matchers.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/transaction_matchers.rs index e6c3f27..836a8a1 100644 --- a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/transaction_matchers.rs +++ b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/transaction_matchers.rs @@ -1,23 +1,28 @@ -use schist_models::{Account, Transaction}; +use schist_models::{Account, Bucket, Transaction}; + +use crate::common::bucket_names::{BUCKET_NAME_FOOD, BUCKET_NAME_INCOME}; use super::ACCOUNT_NAMES_DICT; -pub fn get_transaction_matchers() -> Vec bool>> { +pub fn get_transaction_matchers() -> Vec bool>> { vec![ - Box::new(|t, a| matches_bank_account_initial_transaction(t, a)), - Box::new(|t, a| matches_cash_account_initial_transaction(t, a)), - Box::new(|t, a| matches_income_transaction(t, a, "08")), - Box::new(|t, a| matches_income_transaction(t, a, "09")), - Box::new(|t, a| matches_income_transaction(t, a, "10")), - Box::new(|t, a| matches_income_transaction(t, a, "11")), - Box::new(|t, a| matches_food_transaction(t, a, "08")), - Box::new(|t, a| matches_food_transaction(t, a, "09")), - Box::new(|t, a| matches_food_transaction(t, a, "10")), - Box::new(|t, a| matches_food_transaction(t, a, "11")), + Box::new(|t, a, b| matches_income_transaction(t, a, b, "08")), + Box::new(|t, a, b| matches_income_transaction(t, a, b, "09")), + Box::new(|t, a, b| matches_income_transaction(t, a, b, "10")), + Box::new(|t, a, b| matches_income_transaction(t, a, b, "11")), + Box::new(|t, a, b| matches_food_transaction(t, a, b, "08")), + Box::new(|t, a, b| matches_food_transaction(t, a, b, "09")), + Box::new(|t, a, b| matches_food_transaction(t, a, b, "10")), + Box::new(|t, a, b| matches_food_transaction(t, a, b, "11")), ] } -pub fn matches_bank_account_initial_transaction(t: &Transaction, accounts: &[Account]) -> bool { +pub fn matches_income_transaction( + t: &Transaction, + accounts: &[Account], + buckets: &[Bucket], + month: &str, +) -> bool { let bank_account_id = accounts .iter() .find(|a| a.name == ACCOUNT_NAMES_DICT.bank_account) @@ -25,66 +30,15 @@ pub fn matches_bank_account_initial_transaction(t: &Transaction, accounts: &[Acc .id .clone(); - match t { - Transaction { - account_id, - bucket_id, - date, - id: _id, - description, - counterparty: payee, - amount: 1200_00, - } => { - *account_id == bank_account_id - && *bucket_id == get_bucket_id() - && *date == "2024-08-01".parse().unwrap() - && description == "" - && payee == "Starting Balance" - } - _ => false, - } -} - -pub fn matches_cash_account_initial_transaction(t: &Transaction, accounts: &[Account]) -> bool { - let bank_account_id = accounts + let income_bucket = buckets .iter() - .find(|a| a.name == ACCOUNT_NAMES_DICT.cash_account) - .expect("failed to find cash account") - .id - .clone(); + .find(|c| c.name == BUCKET_NAME_INCOME) + .expect("failed to find income bucket"); match t { Transaction { account_id, - bucket_id, - date, - id: _id, - description, - counterparty: payee, - amount: 45_10, - } => { - *account_id == bank_account_id - && *bucket_id == get_bucket_id() - && *date == "2024-08-01".parse().unwrap() - && description == "" - && payee == "Starting Balance" - } - _ => false, - } -} - -pub fn matches_income_transaction(t: &Transaction, accounts: &[Account], month: &str) -> bool { - let bank_account_id = accounts - .iter() - .find(|a| a.name == ACCOUNT_NAMES_DICT.bank_account) - .expect("failed to find bank account") - .id - .clone(); - - match t { - Transaction { - account_id, - bucket_id, + bucket_id: Some(bucket_id), date, description, id: _id, @@ -92,7 +46,7 @@ pub fn matches_income_transaction(t: &Transaction, accounts: &[Account], month: amount: 846_46, } => { *account_id == bank_account_id - && *bucket_id == get_bucket_id() + && *bucket_id == income_bucket.id && *date == format!("2024-{}-21", month).parse().unwrap() && description == "Salary" && payee == "Employer Ltd" @@ -101,7 +55,12 @@ pub fn matches_income_transaction(t: &Transaction, accounts: &[Account], month: } } -pub fn matches_food_transaction(t: &Transaction, accounts: &[Account], month: &str) -> bool { +pub fn matches_food_transaction( + t: &Transaction, + accounts: &[Account], + buckets: &[Bucket], + month: &str, +) -> bool { let bank_account_id = accounts .iter() .find(|a| a.name == ACCOUNT_NAMES_DICT.bank_account) @@ -109,10 +68,15 @@ pub fn matches_food_transaction(t: &Transaction, accounts: &[Account], month: &s .id .clone(); + let food_bucket = buckets + .iter() + .find(|c| c.name == BUCKET_NAME_FOOD) + .expect("failed to find food bucket"); + match t { Transaction { account_id, - bucket_id, + bucket_id: Some(bucket_id), date, description, id: _id, @@ -120,7 +84,7 @@ pub fn matches_food_transaction(t: &Transaction, accounts: &[Account], month: &s amount: -200_00, } => { *account_id == bank_account_id - && *bucket_id == get_bucket_id() + && *bucket_id == food_bucket.id && *date == format!("2024-{}-08", month).parse().unwrap() && description == "" && payee == "Tesco" @@ -128,7 +92,3 @@ pub fn matches_food_transaction(t: &Transaction, accounts: &[Account], month: &s _ => false, } } - -fn get_bucket_id() -> Option { - todo!() -} diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/resources/actualbudget_test_db.sqlite b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/resources/actualbudget_test_db.sqlite index f8d3f5d..5f89924 100644 Binary files a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/resources/actualbudget_test_db.sqlite and b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/resources/actualbudget_test_db.sqlite differ diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/run.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/run.rs index aff034f..04c21ca 100644 --- a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/run.rs +++ b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/run.rs @@ -2,18 +2,20 @@ pub mod common; use actualbudget_to_schist_transformer::run::run; use common::{ - assert_budget_drip_matches, get_account_matchers, get_account_transfer_matchers, - get_bucket_matchers, get_bucket_transfer_matchers, get_num_expected_budget_drips, - get_transaction_matchers, TestContext, TEST_IN_DB_URL, TEST_OUT_DB_URL, + assert_drip_matches, get_account_matchers, get_account_transfer_matchers, get_bucket_matchers, + get_bucket_transfer_matchers, get_num_expected_drips, get_transaction_matchers, TestContext, + TEST_IN_DB_URL, TEST_OUT_DB_URL, }; use diesel::{Connection, SqliteConnection}; -use schist_models::Pipe; + use schist_queries::{ account_transfers::get_all_account_transfers, accounts::get_all_accounts, bucket_transfers::get_all_bucket_transfers, buckets::get_all_buckets, drips::get_all_drips, pipes::get_all_pipes, transactions::get_all_transactions, }; +use crate::common::get_pipe_matchers; + #[test] fn given_test_database_when_run_then_exports_expected_schist_state() { let _context = TestContext::new(); @@ -49,9 +51,9 @@ fn given_test_database_when_run_then_exports_expected_schist_state() { ); } - assert_eq!(get_num_expected_budget_drips(), drips.len()); + assert_eq!(get_num_expected_drips(), drips.len()); for budget_drip in drips { - assert_budget_drip_matches(&budget_drip, &buckets); + assert_drip_matches(&budget_drip, &buckets); } let bucket_matchers = get_bucket_matchers(); @@ -82,11 +84,19 @@ fn given_test_database_when_run_then_exports_expected_schist_state() { assert!( transaction_matchers .iter() - .any(|m| m(&transaction, &accounts)), + .any(|m| m(&transaction, &accounts, &buckets)), "no match found for transaction {:?}", transaction, ); } - assert_eq!(Vec::::new(), pipes); + let pipe_matchers = get_pipe_matchers(); + assert_eq!(pipe_matchers.len(), pipes.len()); + for pipe in pipes.clone() { + assert!( + pipe_matchers.iter().any(|p| p(&pipe, &buckets)), + "no match found for pipe: {:?}", + pipe + ); + } } -- cgit v1.2.3