50 Commits

Author SHA1 Message Date
c5a3d1743f Merge pull request 'feature/transfer-service' (#12) from feature/transfer-service into master
All checks were successful
Lint / lint-frontend (push) Successful in 23s
Test / test-crates (push) Successful in 1m55s
Lint / lint-crates (push) Successful in 2m23s
Reviewed-on: #12
2026-02-26 21:19:36 +08:00
e8938146f3 test(transfers): add unit tests for transfer service implementation and mock transaction service
All checks were successful
Test / test-crates (pull_request) Successful in 2m2s
Lint / lint-crates (pull_request) Successful in 2m28s
Lint / lint-frontend (pull_request) Successful in 23s
2026-02-26 13:14:14 +00:00
6d4f587e14 feat(transfers): enhance transfer service to utilize transaction service for transaction management 2026-02-26 12:49:17 +00:00
b199e17197 refactor(transfers): improve formatting and structure of transfer service methods 2026-02-26 12:34:42 +00:00
01ee259fe4 feat(transfers): implement transfer service with create, get, and delete functionalities 2026-02-26 12:33:17 +00:00
aad5fd91a7 Merge pull request 'feature/tags-service' (#11) from feature/tags-service into master
All checks were successful
Lint / lint-frontend (push) Successful in 27s
Test / test-crates (push) Successful in 1m47s
Lint / lint-crates (push) Successful in 2m12s
Reviewed-on: #11
2026-02-26 20:11:44 +08:00
5b13aa4170 fmt: reformat
All checks were successful
Lint / lint-frontend (pull_request) Successful in 33s
Test / test-crates (pull_request) Successful in 1m56s
Lint / lint-crates (pull_request) Successful in 2m25s
2026-02-26 12:08:31 +00:00
fff5d61f22 chore(mod): comment out scheduled module import
Some checks failed
Lint / lint-frontend (pull_request) Successful in 21s
Test / test-crates (pull_request) Successful in 1m54s
Lint / lint-crates (pull_request) Failing after 2m19s
2026-02-26 12:01:37 +00:00
0bf05b17df test(tags): add unit tests for tag creation, retrieval, updating, and deletion
Some checks failed
Lint / lint-frontend (pull_request) Successful in 19s
Test / test-crates (pull_request) Failing after 1m49s
Lint / lint-crates (pull_request) Failing after 2m21s
2026-02-26 11:36:43 +00:00
e6773e4b93 feat(tags): add get_tag_by_id command and input validation for tag creation and updates 2026-02-26 07:05:23 +00:00
c77d9f256e feat(tags): enhance tag service integration with transaction service and add CRUD operations for transaction-tag associations 2026-02-26 06:33:55 +00:00
92bfd01c44 feat(tags): integrate tag service into application state and command handling 2026-02-26 06:31:44 +00:00
7b33b0637e feat(tags): implement CRUD operations for tags service 2026-02-26 05:03:26 +00:00
eb23c17ee1 Merge pull request 'feature/transcation-service' (#10) from feature/transcation-service into master
All checks were successful
Lint / lint-frontend (push) Successful in 19s
Test / test-crates (push) Successful in 1m34s
Lint / lint-crates (push) Successful in 2m2s
Reviewed-on: #10
2026-02-26 13:01:21 +08:00
77c6a2d156 refactor(transactions): improve code formatting and error handling in transaction service methods
All checks were successful
Lint / lint-frontend (pull_request) Successful in 22s
Test / test-crates (pull_request) Successful in 1m47s
Lint / lint-crates (pull_request) Successful in 2m14s
2026-02-26 04:38:20 +00:00
abc040310b refactor(tests): improve formatting of date parsing in test cases 2026-02-25 14:24:17 +00:00
6c0c5920ed feat(transactions): add bulk create, bulk delete, and statistics commands 2026-02-25 14:24:11 +00:00
d60a573c64 feat(transactions): refactor balance calculation using BalanceCalculator service 2026-02-25 10:58:23 +00:00
e731c45a71 feat(transactions): implement transaction management commands 2026-02-25 10:58:03 +00:00
bfbb771cbf feat(transactions): add input and output types for transaction management
- Introduced `CreateTransactionInput`, `BulkCreateTransactionInput`, `UpdateTransactionInput`, and `TransactionFilter` structs for handling transaction creation and updates.
- Added `BulkDeleteInput` and `BulkDeleteResult` structs for bulk deletion of transactions.
- Created `TransactionStatistics` struct to encapsulate transaction statistics data.
- Established a new module for transaction types in `mod.rs`.
- Implemented `TransactionWithTags` struct in outputs for returning transactions with associated tags.
- Updated `AppState` to include a transaction service, ensuring it is initialized and accessible.
2026-02-25 10:57:48 +00:00
30eb0b71cc feat: add balance calculator service module and transaction type handling 2026-02-25 10:57:24 +00:00
4e4a656c88 Merge pull request 'feat: implement account management commands and services' (#9) from feature/accounts-service into master
All checks were successful
Lint / lint-frontend (push) Successful in 20s
Test / test-crates (push) Successful in 1m46s
Lint / lint-crates (push) Successful in 2m17s
Reviewed-on: #9
2026-02-25 18:04:48 +08:00
3ff421c200 feat: extend setting type handling to include Language, View, DisplayMode, Theme, and DateOfWeek
All checks were successful
Lint / lint-frontend (pull_request) Successful in 38s
Test / test-crates (pull_request) Successful in 1m48s
Lint / lint-crates (pull_request) Successful in 2m16s
2026-02-25 09:59:26 +00:00
75efe5768a fix: update database connection options to use ConnectOptions for improved logging 2026-02-25 09:58:59 +00:00
620df5780b refactor: improve HEX_COLOR_PATTERN initialization with Clippy expectation
All checks were successful
Lint / lint-crates (pull_request) Successful in 1m5s
Lint / lint-frontend (pull_request) Successful in 16s
Test / test-crates (pull_request) Successful in 1m25s
2026-02-23 08:51:51 +00:00
6b987181a8 refactor: improve formatting of apply_transaction_to_balance function and add TODO for transaction service integration
Some checks failed
Lint / lint-crates (pull_request) Failing after 3m2s
Lint / lint-frontend (pull_request) Successful in 15s
Test / test-crates (pull_request) Successful in 1m47s
2026-02-23 08:48:54 +00:00
bf04d8d2da feat: add recalculate_account_balance command and enhance account validation 2026-02-23 08:48:43 +00:00
7ffc3bac00 feat: enhance account management with filtering and validation improvements
All checks were successful
Lint / lint-frontend (pull_request) Successful in 21s
Lint / lint-crates (pull_request) Successful in 3m6s
Test / test-crates (pull_request) Successful in 4m6s
2026-02-23 04:53:37 +00:00
7448bbd5e0 feat: implement account management commands and services
All checks were successful
Lint / lint-frontend (pull_request) Successful in 19s
Test / test-crates (pull_request) Successful in 1m33s
Lint / lint-crates (pull_request) Successful in 2m1s
- Added account commands for creating, retrieving, updating, archiving, deleting accounts, and fetching account balances.
- Created account service with async methods for account operations.
- Defined input and output types for account operations.
- Integrated account service into the application state and service factory.
- Added tests for account service methods to ensure functionality and correctness.
2026-02-21 03:14:02 +00:00
8013f2ad61 Merge pull request 'feature/exchange-rate-service' (#8) from feature/exchange-rate-service into master
All checks were successful
Lint / lint-frontend (push) Successful in 19s
Test / test-crates (push) Successful in 1m30s
Lint / lint-crates (push) Successful in 1m58s
Reviewed-on: #8
2026-02-21 10:26:35 +08:00
a0d5bae160 feat: implement on_app_start to set default exchange rate adapter in settings
All checks were successful
Lint / lint-frontend (pull_request) Successful in 20s
Test / test-crates (pull_request) Successful in 1m29s
Lint / lint-crates (pull_request) Successful in 1m55s
2026-02-21 02:09:52 +00:00
f526d9ab2b fmt: fix formatting
All checks were successful
Lint / lint-frontend (pull_request) Successful in 20s
Test / test-crates (pull_request) Successful in 1m28s
Lint / lint-crates (pull_request) Successful in 1m55s
2026-02-20 14:29:56 +00:00
5b3a29f615 feat: implement exchange rate commands and service integration
Some checks failed
Lint / lint-frontend (pull_request) Successful in 20s
Test / test-crates (pull_request) Successful in 1m35s
Lint / lint-crates (pull_request) Failing after 1m59s
2026-02-20 14:26:06 +00:00
49291002ac test: improve error handling in assertions for currency data fetching tests 2026-02-20 14:22:56 +00:00
6f3c5ef106 feat: refactor ExchangeRateService to use DatabaseConnection and update service creation
Some checks failed
Lint / lint-frontend (pull_request) Successful in 21s
Test / test-crates (pull_request) Successful in 1m30s
Lint / lint-crates (pull_request) Failing after 1m58s
2026-02-20 13:40:05 +00:00
d57eeef78f fix: update exchange rate data types to use f64
All checks were successful
Lint / lint-frontend (pull_request) Successful in 21s
Test / test-crates (pull_request) Successful in 1m30s
Lint / lint-crates (pull_request) Successful in 1m57s
2026-02-20 13:00:14 +00:00
a6625fc55c feat: enhance error logging for currency fetching and adapter retrieval
All checks were successful
Lint / lint-frontend (pull_request) Successful in 20s
Test / test-crates (pull_request) Successful in 1m30s
Lint / lint-crates (pull_request) Successful in 1m58s
2026-02-20 10:19:29 +00:00
9dc8166225 feat: add get_current_adapter method to ExchangeRateService implementation
All checks were successful
Lint / lint-frontend (pull_request) Successful in 21s
Lint / lint-crates (pull_request) Successful in 3m4s
Test / test-crates (pull_request) Successful in 4m12s
2026-02-20 10:10:50 +00:00
716223e45c feat: add get_setting_with_prefix method to SettingsService implementation 2026-02-20 10:01:43 +00:00
8f18b8692f feat: implement default adapter retrieval and update settings in ExchangeRateService 2026-02-20 09:59:07 +00:00
e99feace0e Implement ExchangeRateApiAdapter and related service functionality
- Added ExchangeRateApiAdapter for fetching exchange rates from ExchangeRate-API.
- Implemented ExchangeRateService with caching and database storage for exchange rates.
- Created a modular structure for exchange rate adapters.
- Added tests for the new adapter and service functionalities, ensuring correct behavior and error handling.
- Included support for fetching and storing exchange rates, as well as retrieving supported currencies.
2026-02-20 09:47:36 +00:00
51246ab378 Merge pull request 'feature/settings-service' (#7) from feature/settings-service into master
All checks were successful
Lint / lint-frontend (push) Successful in 21s
Test / test-crates (push) Successful in 1m22s
Lint / lint-crates (push) Successful in 1m48s
Reviewed-on: #7
2026-02-20 14:06:13 +08:00
07accb0265 feat: add get_setting method to SettingsService and implement in SettingsServiceImpl
All checks were successful
Lint / lint-frontend (pull_request) Successful in 1m5s
Lint / lint-crates (pull_request) Successful in 4m32s
Test / test-crates (pull_request) Successful in 5m35s
2026-02-20 05:33:55 +00:00
3be04939c9 feat: implement settings service commands and integrate into app state
Some checks failed
Lint / lint-frontend (pull_request) Successful in 21s
Lint / lint-crates (pull_request) Failing after 0s
Test / test-crates (pull_request) Successful in 3m28s
2026-02-20 05:15:14 +00:00
ac66ae16aa feat: add GitHub Actions workflow for running tests on push and pull request 2026-02-20 04:50:29 +00:00
c280f7ff8b feat: add tokio as a dev dependency and implement unit tests for settings service 2026-02-20 04:40:11 +00:00
acc0668392 feat: implement settings service with CRUD operations and integrate into app state 2026-02-19 03:50:37 +00:00
88e8640386 feat: update Cargo.toml to use Rust edition 2024 and add struct_iterable dependency; update Cargo.lock with new packages 2026-02-19 03:50:11 +00:00
15dfcd2e73 feat: enhance database connection handling with ConnectionSource enum and implement ConnectionTrait for better abstraction 2026-02-19 02:44:58 +00:00
4e1d383285 Merge pull request 'feature/actions' (#6) from feature/actions into master
All checks were successful
Lint / lint-frontend (push) Successful in 22s
Lint / lint-crates (push) Successful in 1m18s
Reviewed-on: finwise/finewise#6
2026-02-17 11:34:19 +08:00
52 changed files with 9807 additions and 19 deletions

30
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
name: Test
on:
push:
branches:
- master
pull_request:
branches:
- master
workflow_dispatch:
jobs:
test-crates:
runs-on: ubuntu-latest
container:
image: gitea.gwmc.dev/finwise/finwise-ci:latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup Rust, checkout and restore caches
uses: ./.github/actions/setup-rust
- name: Run tests
run: |
cd src-tauri
cargo test --all-features

282
Cargo.lock generated
View File

@@ -752,6 +752,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -775,7 +785,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
dependencies = [
"bitflags 2.11.0",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types 0.5.0",
"libc",
@@ -788,7 +798,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.11.0",
"core-foundation",
"core-foundation 0.10.1",
"libc",
]
@@ -1175,6 +1185,15 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "endi"
version = "1.1.1"
@@ -1218,6 +1237,15 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "erased-serde"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c138974f9d5e7fe373eb04df7cae98833802ae4b11c24ac7039a21d5af4b26c"
dependencies = [
"serde",
]
[[package]]
name = "erased-serde"
version = "0.4.9"
@@ -1317,18 +1345,23 @@ version = "0.1.0"
dependencies = [
"async-trait",
"chrono",
"lazy_static",
"log",
"migration",
"regex",
"reqwest 0.12.28",
"rust_decimal",
"sea-orm",
"serde",
"serde_json",
"sha2",
"struct_iterable",
"tauri",
"tauri-build",
"tauri-plugin-log",
"tauri-plugin-opener",
"thiserror 2.0.18",
"tokio",
"uuid",
]
@@ -1842,6 +1875,25 @@ dependencies = [
"syn 2.0.115",
]
[[package]]
name = "h2"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.13.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -1989,6 +2041,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
@@ -2000,6 +2053,38 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -2018,9 +2103,11 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -3785,6 +3872,46 @@ dependencies = [
"bytecheck",
]
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "reqwest"
version = "0.13.2"
@@ -3819,6 +3946,20 @@ dependencies = [
"web-sys",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rkyv"
version = "0.7.46"
@@ -3906,6 +4047,39 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
dependencies = [
"once_cell",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -4168,7 +4342,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38"
dependencies = [
"bitflags 2.11.0",
"core-foundation",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
@@ -4228,7 +4402,7 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058"
dependencies = [
"erased-serde",
"erased-serde 0.4.9",
"serde",
"serde_core",
"typeid",
@@ -4813,6 +4987,35 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "struct_iterable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "849a064c6470a650b72e41fa6c057879b68f804d113af92900f27574828e7712"
dependencies = [
"struct_iterable_derive",
"struct_iterable_internal",
]
[[package]]
name = "struct_iterable_derive"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bb939ce88a43ea4e9d012f2f6b4cc789deb2db9d47bad697952a85d6978662c"
dependencies = [
"erased-serde 0.3.31",
"proc-macro2",
"quote",
"struct_iterable_internal",
"syn 2.0.115",
]
[[package]]
name = "struct_iterable_internal"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9426b2a0c03e6cc2ea8dbc0168dbbf943f88755e409fb91bcb8f6a268305f4a"
[[package]]
name = "strum"
version = "0.27.2"
@@ -4878,6 +5081,27 @@ dependencies = [
"syn 2.0.115",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.11.0",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "system-deps"
version = "6.2.2"
@@ -4899,7 +5123,7 @@ checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7"
dependencies = [
"bitflags 2.11.0",
"block2",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics",
"crossbeam-channel",
"dispatch",
@@ -4984,7 +5208,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest",
"reqwest 0.13.2",
"serde",
"serde_json",
"serde_repr",
@@ -5387,6 +5611,26 @@ dependencies = [
"syn 2.0.115",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.18"
@@ -5730,6 +5974,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@@ -6245,6 +6495,17 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]]
name = "windows-result"
version = "0.3.4"
@@ -6299,6 +6560,15 @@ dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"

View File

@@ -37,6 +37,11 @@ dev DISPLAY='1':
# Check the start-vnc output for the correct DISPLAY value if you have multiple VNC sessions running
DISPLAY=:{{DISPLAY}} pnpm tauri dev
test-cargo-integration:
cargo test \
--package finwise integration_tests \
-- --ignored
# docker images for ci
DOCKER_IMAGE_NAME := 'gitea.gwmc.dev/finwise/finwise-ci:latest'

View File

@@ -3,7 +3,7 @@ name = "finwise"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
edition = "2024"
rust-version = "1.85.0"
[lib]
@@ -20,6 +20,7 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sea-orm = { workspace = true }
@@ -29,9 +30,16 @@ thiserror = "2"
rust_decimal = "1"
uuid = { version = "1", features = ["v4"] }
async-trait = "0.1"
reqwest = { version = "0.12", features = ["json"] }
sha2 = "0.10"
tauri-plugin-log = "2.8.0"
log = "0.4.29"
struct_iterable = "0.1.1"
regex = "1"
lazy_static = "1"
[dev-dependencies]
sea-orm = { workspace = true, features = ["mock"] }
[profile.dev]
incremental = true

View File

@@ -0,0 +1,99 @@
use std::str::FromStr;
use crate::{
errors::CommandResult,
services::accounts::{
service::AccountModel,
types::{
inputs::{AccountFilter, AccountType, CreateAccountInput, UpdateAccountInput},
outputs::AccountBalance,
},
},
state::AppState,
};
#[tauri::command]
pub async fn create_account(
state: tauri::State<'_, AppState>,
input: CreateAccountInput,
) -> CommandResult<AccountModel> {
state.account_service().create_account(input, None).await
}
#[tauri::command]
pub async fn get_accounts(
state: tauri::State<'_, AppState>,
include_archived: Option<bool>,
account_type: Option<String>,
limit: Option<u64>,
offset: Option<u64>,
) -> CommandResult<Vec<AccountModel>> {
let filter = AccountFilter {
account_type: account_type.and_then(|s| AccountType::from_str(&s).ok()), // Convert string to AccountType enum
include_archived,
limit,
offset,
};
state.account_service().get_accounts(filter, None).await
}
#[tauri::command]
pub async fn get_account(
state: tauri::State<'_, AppState>,
id: String,
) -> CommandResult<AccountModel> {
state.account_service().get_account(id, None).await
}
#[tauri::command]
pub async fn update_account(
state: tauri::State<'_, AppState>,
id: String,
updates: UpdateAccountInput,
) -> CommandResult<AccountModel> {
state
.account_service()
.update_account(id, updates, None)
.await
}
#[tauri::command]
pub async fn archive_account(
state: tauri::State<'_, AppState>,
id: String,
archived: bool,
) -> CommandResult<()> {
state
.account_service()
.archive_account(id, archived, None)
.await
}
#[tauri::command]
pub async fn delete_account(state: tauri::State<'_, AppState>, id: String) -> CommandResult<()> {
state.account_service().delete_account(id, None).await
}
#[tauri::command]
pub async fn get_account_balance(
state: tauri::State<'_, AppState>,
id: String,
as_of_date: Option<String>,
) -> CommandResult<AccountBalance> {
state
.account_service()
.get_balance(id, as_of_date, None)
.await
}
#[tauri::command]
pub async fn recalculate_account_balance(
state: tauri::State<'_, AppState>,
id: String,
) -> CommandResult<String> {
let balance = state
.account_service()
.recalculate_balance(&id, None)
.await?;
Ok(balance.to_string())
}

View File

@@ -0,0 +1,51 @@
use crate::errors::CommandResult;
use crate::services::exchange_rate::ExchangeRateAdapterInfo;
use crate::state::AppState;
#[tauri::command]
pub async fn get_exchange_rate(
state: tauri::State<'_, AppState>,
from_currency: String,
to_currency: String,
) -> CommandResult<String> {
let rate = state
.exchange_rate_service()
.get_exchange_rate(&from_currency, &to_currency)
.await?;
Ok(rate.to_string())
}
#[tauri::command]
pub async fn get_supported_currencies(
state: tauri::State<'_, AppState>,
) -> CommandResult<Vec<String>> {
Ok(state
.exchange_rate_service()
.get_supported_currencies()
.await)
}
#[tauri::command]
pub async fn get_available_exchange_rate_adapters(
state: tauri::State<'_, AppState>,
) -> CommandResult<Vec<ExchangeRateAdapterInfo>> {
Ok(state.exchange_rate_service().get_available_adapters().await)
}
#[tauri::command]
pub async fn set_exchange_rate_adapter(
state: tauri::State<'_, AppState>,
adapter_name: String,
) -> CommandResult<()> {
state
.exchange_rate_service()
.set_adapter(&adapter_name)
.await
}
#[tauri::command]
pub async fn get_current_exchange_rate_adapter(
state: tauri::State<'_, AppState>,
) -> CommandResult<String> {
state.exchange_rate_service().get_current_adapter().await
}

View File

@@ -1 +1,6 @@
pub mod accounts;
pub mod exchange_rate;
pub mod settings;
pub mod tags;
pub mod transactions;
pub mod transfers;

View File

@@ -0,0 +1,28 @@
use crate::errors::CommandResult;
use crate::services::settings::types::settings::{Settings, UpdateSettingsInput};
use crate::state::AppState;
#[tauri::command]
pub async fn get_settings(state: tauri::State<'_, AppState>) -> CommandResult<Settings> {
state.settings_service().get_settings(None).await
}
#[tauri::command]
pub async fn update_setting(
state: tauri::State<'_, AppState>,
key: String,
value: String,
) -> CommandResult<()> {
state
.settings_service()
.update_setting(key, value, None)
.await
}
#[tauri::command]
pub async fn update_settings(
state: tauri::State<'_, AppState>,
input: UpdateSettingsInput,
) -> CommandResult<()> {
state.settings_service().update_settings(input, None).await
}

View File

@@ -0,0 +1,45 @@
use crate::errors::CommandResult;
use crate::services::tags::service::TagModel;
use crate::services::tags::types::inputs::{CreateTagInput, UpdateTagInput};
use crate::state::AppState;
#[tauri::command]
pub async fn create_tag(
state: tauri::State<'_, AppState>,
input: CreateTagInput,
) -> CommandResult<TagModel> {
state.tag_service().create_tag(input, None).await
}
#[tauri::command]
pub async fn get_tag_by_id(
state: tauri::State<'_, AppState>,
id: String,
) -> CommandResult<Option<TagModel>> {
state.tag_service().get_tag_by_id(id, None).await
}
#[tauri::command]
pub async fn get_tags(
state: tauri::State<'_, AppState>,
include_system: Option<bool>,
) -> CommandResult<Vec<TagModel>> {
state
.tag_service()
.get_tags(include_system.unwrap_or(true), None)
.await
}
#[tauri::command]
pub async fn update_tag(
state: tauri::State<'_, AppState>,
id: String,
updates: UpdateTagInput,
) -> CommandResult<TagModel> {
state.tag_service().update_tag(id, updates, None).await
}
#[tauri::command]
pub async fn delete_tag(state: tauri::State<'_, AppState>, id: String) -> CommandResult<()> {
state.tag_service().delete_tag(id, None).await
}

View File

@@ -0,0 +1,116 @@
use crate::errors::CommandResult;
use crate::services::transactions::types::{
inputs::{
BulkCreateTransactionInput, BulkDeleteInput, CreateTransactionInput, TransactionFilter,
TransactionStatistics, UpdateTransactionInput,
},
outputs::{BulkDeleteResult, TransactionWithTags},
};
use crate::state::AppState;
#[tauri::command]
pub async fn create_transaction(
state: tauri::State<'_, AppState>,
input: CreateTransactionInput,
) -> CommandResult<TransactionWithTags> {
state
.transaction_service()
.create_transaction(input, None)
.await
}
#[tauri::command]
pub async fn get_transactions(
state: tauri::State<'_, AppState>,
filter: TransactionFilter,
) -> CommandResult<Vec<TransactionWithTags>> {
state
.transaction_service()
.get_transactions(filter, None)
.await
}
#[tauri::command]
pub async fn get_transaction(
state: tauri::State<'_, AppState>,
id: String,
) -> CommandResult<TransactionWithTags> {
state.transaction_service().get_transaction(id, None).await
}
#[tauri::command]
pub async fn update_transaction(
state: tauri::State<'_, AppState>,
id: String,
updates: UpdateTransactionInput,
) -> CommandResult<TransactionWithTags> {
state
.transaction_service()
.update_transaction(id, updates, None)
.await
}
#[tauri::command]
pub async fn delete_transaction(
state: tauri::State<'_, AppState>,
id: String,
) -> CommandResult<()> {
state
.transaction_service()
.delete_transaction(id, None)
.await
}
#[tauri::command]
pub async fn get_transactions_needing_review(
state: tauri::State<'_, AppState>,
) -> CommandResult<Vec<TransactionWithTags>> {
state
.transaction_service()
.get_transactions_needing_review(None)
.await
}
#[tauri::command]
pub async fn confirm_transaction(
state: tauri::State<'_, AppState>,
id: String,
) -> CommandResult<()> {
state
.transaction_service()
.confirm_transaction(id, None)
.await
}
#[tauri::command]
pub async fn bulk_create_transactions(
state: tauri::State<'_, AppState>,
input: BulkCreateTransactionInput,
) -> CommandResult<Vec<TransactionWithTags>> {
state
.transaction_service()
.bulk_create_transactions(input.transactions, None)
.await
}
#[tauri::command]
pub async fn bulk_delete_transactions(
state: tauri::State<'_, AppState>,
input: BulkDeleteInput,
) -> CommandResult<BulkDeleteResult> {
state
.transaction_service()
.bulk_delete_transactions(input.ids, None)
.await
}
#[tauri::command]
pub async fn get_transaction_statistics(
state: tauri::State<'_, AppState>,
filter: TransactionFilter,
) -> CommandResult<TransactionStatistics> {
state
.transaction_service()
.get_transaction_statistics(filter, None)
.await
}

View File

@@ -0,0 +1,30 @@
use crate::errors::CommandResult;
use crate::services::transfers::types::inputs::CreateTransferInput;
use crate::services::transfers::types::outputs::TransferWithTransactions;
use crate::state::AppState;
#[tauri::command]
pub async fn create_transfer(
state: tauri::State<'_, AppState>,
input: CreateTransferInput,
) -> CommandResult<TransferWithTransactions> {
state.transfer_service().create_transfer(input, None).await
}
#[tauri::command]
pub async fn get_transfers(
state: tauri::State<'_, AppState>,
account_id: Option<String>,
start_date: Option<String>,
end_date: Option<String>,
) -> CommandResult<Vec<TransferWithTransactions>> {
state
.transfer_service()
.get_transfers(account_id, start_date, end_date, None)
.await
}
#[tauri::command]
pub async fn delete_transfer(state: tauri::State<'_, AppState>, id: String) -> CommandResult<()> {
state.transfer_service().delete_transfer(id, None).await
}

View File

@@ -1,7 +1,10 @@
use std::path::PathBuf;
use log::error;
use sea_orm::{Database, DatabaseConnection, DbErr};
use sea_orm::{
ConnectOptions, ConnectionTrait, Database, DatabaseConnection, DatabaseTransaction, DbBackend,
DbErr, ExecResult, QueryResult, Statement,
};
use tauri::{AppHandle, Manager};
use super::migrations;
@@ -24,10 +27,18 @@ pub(super) async fn establish_connection(
let db_path = app_dir.join(DATABASE_PATH);
let url = format!("sqlite://{}?mode=rwc", db_path.display());
let mut opt = ConnectOptions::new(url);
opt.sqlx_logging_level(log::LevelFilter::Debug);
opt.min_connections(0);
opt.max_connections(10);
opt.sqlx_slow_statements_logging_settings(
log::LevelFilter::Warn,
std::time::Duration::from_secs(1),
);
println!("Connecting to database at: {}", db_path.display());
let db = Database::connect(&url).await?;
let db = Database::connect(opt).await?;
// Enable foreign keys and set pragmas
sea_orm::ConnectionTrait::execute_unprepared(
@@ -53,3 +64,98 @@ pub(super) fn get_database_path(app_handle: &AppHandle) -> PathBuf {
})
.unwrap_or_else(|_| PathBuf::from(DATABASE_PATH)) // Fallback to current directory if app data dir is not accessible
}
pub enum ConnectionSource<'a> {
Transaction(&'a DatabaseTransaction),
Connection(&'a DatabaseConnection),
}
#[async_trait::async_trait]
impl ConnectionTrait for &ConnectionSource<'_> {
fn get_database_backend(&self) -> DbBackend {
match self {
ConnectionSource::Transaction(tx) => tx.get_database_backend(),
ConnectionSource::Connection(conn) => conn.get_database_backend(),
}
}
async fn execute_raw(&self, stmt: Statement) -> Result<ExecResult, DbErr> {
match self {
ConnectionSource::Transaction(tx) => tx.execute_raw(stmt).await,
ConnectionSource::Connection(conn) => conn.execute_raw(stmt).await,
}
}
async fn execute_unprepared(&self, sql: &str) -> Result<ExecResult, DbErr> {
match self {
ConnectionSource::Transaction(tx) => tx.execute_unprepared(sql).await,
ConnectionSource::Connection(conn) => conn.execute_unprepared(sql).await,
}
}
async fn query_one_raw(&self, stmt: Statement) -> Result<Option<QueryResult>, DbErr> {
match self {
ConnectionSource::Transaction(tx) => tx.query_one_raw(stmt).await,
ConnectionSource::Connection(conn) => conn.query_one_raw(stmt).await,
}
}
async fn query_all_raw(&self, stmt: Statement) -> Result<Vec<QueryResult>, DbErr> {
match self {
ConnectionSource::Transaction(tx) => tx.query_all_raw(stmt).await,
ConnectionSource::Connection(conn) => conn.query_all_raw(stmt).await,
}
}
fn is_mock_connection(&self) -> bool {
match self {
ConnectionSource::Transaction(tx) => tx.is_mock_connection(),
ConnectionSource::Connection(conn) => conn.is_mock_connection(),
}
}
}
#[async_trait::async_trait]
impl ConnectionTrait for ConnectionSource<'_> {
fn get_database_backend(&self) -> DbBackend {
match self {
ConnectionSource::Transaction(tx) => tx.get_database_backend(),
ConnectionSource::Connection(conn) => conn.get_database_backend(),
}
}
async fn execute_raw(&self, stmt: Statement) -> Result<ExecResult, DbErr> {
match self {
ConnectionSource::Transaction(tx) => tx.execute_raw(stmt).await,
ConnectionSource::Connection(conn) => conn.execute_raw(stmt).await,
}
}
async fn execute_unprepared(&self, sql: &str) -> Result<ExecResult, DbErr> {
match self {
ConnectionSource::Transaction(tx) => tx.execute_unprepared(sql).await,
ConnectionSource::Connection(conn) => conn.execute_unprepared(sql).await,
}
}
async fn query_one_raw(&self, stmt: Statement) -> Result<Option<QueryResult>, DbErr> {
match self {
ConnectionSource::Transaction(tx) => tx.query_one_raw(stmt).await,
ConnectionSource::Connection(conn) => conn.query_one_raw(stmt).await,
}
}
async fn query_all_raw(&self, stmt: Statement) -> Result<Vec<QueryResult>, DbErr> {
match self {
ConnectionSource::Transaction(tx) => tx.query_all_raw(stmt).await,
ConnectionSource::Connection(conn) => conn.query_all_raw(stmt).await,
}
}
fn is_mock_connection(&self) -> bool {
match self {
ConnectionSource::Transaction(tx) => tx.is_mock_connection(),
ConnectionSource::Connection(conn) => conn.is_mock_connection(),
}
}
}

View File

@@ -1,5 +1,5 @@
pub mod entities;
pub mod service;
mod connection;
pub mod connection;
mod migrations;

View File

@@ -14,6 +14,9 @@ pub enum AppError {
#[error("Invalid amount: {0}")]
InvalidAmount(String),
#[error("Invalid data: {0}")]
InvalidData(String),
#[error("Currency mismatch: expected {expected}, got {actual}")]
CurrencyMismatch { expected: String, actual: String },
@@ -38,6 +41,7 @@ enum ErrorKind {
Validation(String),
NotFound(String),
InvalidAmount(String),
InvalidData(String),
CurrencyMismatch { expected: String, actual: String },
Io(String),
Serialization(String),
@@ -57,6 +61,7 @@ impl serde::Serialize for AppError {
Self::Validation(_) => ErrorKind::Validation(error_message),
Self::NotFound(_) => ErrorKind::NotFound(error_message),
Self::InvalidAmount(_) => ErrorKind::InvalidAmount(error_message),
Self::InvalidData(_) => ErrorKind::InvalidData(error_message),
Self::CurrencyMismatch { expected, actual } => ErrorKind::CurrencyMismatch {
expected: expected.clone(),
actual: actual.clone(),

View File

@@ -20,9 +20,15 @@ async fn setup_app(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error
// Establish database connection
let db = db::service::DbService::new(app_handle).await?;
let services: services::ServiceFactoryResult =
services::ServiceFactory::create_services(db.get_connection().clone()).await;
// Create app state with all services
let app_state = AppState::new(db).await;
let app_state = AppState::new(db, services).await;
// run migrations
app_state.db().run_migrations().await?;
app_state.on_app_start().await?;
// Manage the state with Tauri
app.manage(app_state);
@@ -58,7 +64,48 @@ pub fn run() {
});
Ok(())
})
.invoke_handler(tauri::generate_handler![])
.invoke_handler(tauri::generate_handler![
// Account commands
commands::accounts::create_account,
commands::accounts::get_accounts,
commands::accounts::get_account,
commands::accounts::update_account,
commands::accounts::archive_account,
commands::accounts::delete_account,
commands::accounts::get_account_balance,
commands::accounts::recalculate_account_balance,
// Transaction commands
commands::transactions::create_transaction,
commands::transactions::get_transactions,
commands::transactions::get_transaction,
commands::transactions::update_transaction,
commands::transactions::delete_transaction,
commands::transactions::get_transactions_needing_review,
commands::transactions::confirm_transaction,
commands::transactions::bulk_create_transactions,
commands::transactions::bulk_delete_transactions,
commands::transactions::get_transaction_statistics,
// Transfer commands
commands::transfers::create_transfer,
commands::transfers::get_transfers,
commands::transfers::delete_transfer,
// Exchange rate commands
commands::exchange_rate::get_exchange_rate,
commands::exchange_rate::get_supported_currencies,
commands::exchange_rate::get_available_exchange_rate_adapters,
commands::exchange_rate::set_exchange_rate_adapter,
commands::exchange_rate::get_current_exchange_rate_adapter,
// Settings commands
commands::settings::get_settings,
commands::settings::update_setting,
commands::settings::update_settings,
// Tag commands
commands::tags::create_tag,
commands::tags::get_tag_by_id,
commands::tags::get_tags,
commands::tags::update_tag,
commands::tags::delete_tag,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@@ -0,0 +1,2 @@
pub mod service;
pub mod types;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,185 @@
use lazy_static::lazy_static;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
lazy_static! {
static ref HEX_COLOR_PATTERN: regex::Regex =
#[expect(clippy::expect_used)]
regex::Regex::new(r"^#([0-9A-Fa-f]{3,4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$")
.expect("Invalid hex color regex pattern");
}
const CHECKING: &str = "checking";
const SAVINGS: &str = "savings";
const CREDIT_CARD: &str = "credit_card";
const INVESTMENT: &str = "investment";
const LOAN: &str = "loan";
const CASH: &str = "cash";
#[derive(Debug, Deserialize, Serialize, Clone)]
pub enum AccountType {
#[serde(rename = "checking")]
Checking,
#[serde(rename = "savings")]
Savings,
#[serde(rename = "credit_card")]
CreditCard,
#[serde(rename = "investment")]
Investment,
#[serde(rename = "loan")]
Loan,
#[serde(rename = "cash")]
Cash,
// the provided string will be used as the account type, allowing for custom types without needing to update the enum
Other(String),
}
impl std::str::FromStr for AccountType {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
CHECKING => Ok(AccountType::Checking),
SAVINGS => Ok(AccountType::Savings),
CREDIT_CARD => Ok(AccountType::CreditCard),
INVESTMENT => Ok(AccountType::Investment),
LOAN => Ok(AccountType::Loan),
CASH => Ok(AccountType::Cash),
other => Ok(AccountType::Other(other.to_string())),
}
}
}
impl From<AccountType> for String {
fn from(account_type: AccountType) -> Self {
match account_type {
AccountType::Other(s) => s,
_ => serde_json::to_string(&account_type)
.unwrap_or_else(|err| {
log::error!("Failed to serialize AccountType: {}", err);
"\"unknown\"".to_string()
})
.replace('"', ""),
}
}
}
#[derive(Debug, Deserialize)]
pub struct CreateAccountInput {
pub name: String,
pub account_type: AccountType,
pub currency: String,
pub initial_balance: String,
// hex color code, e.g. #FF0000
pub color: Option<String>,
pub icon: Option<String>,
}
impl CreateAccountInput {
/// Validates create account input
pub fn validate(&self) -> Result<(), String> {
if self.name.trim().is_empty() {
return Err("Account name is required".to_string());
}
if self.name.len() > 100 {
return Err("Account name cannot exceed 100 characters".to_string());
}
let currency_upper = self.currency.to_uppercase();
if currency_upper.len() != 3 {
return Err("Currency code must be 3 characters (ISO 4217)".to_string());
}
if !currency_upper.chars().all(|c| c.is_ascii_alphabetic()) {
return Err("Currency code must contain only letters".to_string());
}
// Validate account type
let _account_type: AccountType = self.account_type.clone();
if matches!(self.account_type, AccountType::Other(ref s) if s.trim().is_empty()) {
return Err("Account type cannot be empty".to_string());
}
if Decimal::from_str_exact(&self.initial_balance).is_err() {
return Err("Initial balance must be a valid decimal number".to_string());
}
if let Some(ref color) = self.color {
if !color.trim().is_empty() && !HEX_COLOR_PATTERN.is_match(color) {
return Err(
"Color must be a valid hex code (e.g., #FF0000 or #FF0000FF)".to_string(),
);
}
}
Ok(())
}
}
#[derive(Debug, Deserialize, Default)]
pub struct UpdateAccountInput {
pub name: Option<String>,
pub account_type: Option<AccountType>,
pub currency: Option<String>,
pub initial_balance: Option<String>,
// hex color code, e.g. #FF0000
pub color: Option<String>,
pub icon: Option<String>,
pub sort_order: Option<i32>,
pub is_active: Option<bool>,
pub is_archived: Option<bool>,
pub include_in_net_worth: Option<bool>,
pub show_in_combined_view: Option<bool>,
}
impl UpdateAccountInput {
pub fn validate(&self) -> Result<(), String> {
// Validate name if provided
if let Some(name) = &self.name {
if name.trim().is_empty() {
return Err("Account name cannot be empty".to_string());
}
if name.len() > 100 {
return Err("Account name cannot exceed 100 characters".to_string());
}
}
// Validate currency code if provided
if let Some(currency) = &self.currency {
let currency_upper = currency.to_uppercase();
if currency_upper.len() != 3 {
return Err("Currency code must be 3 characters (ISO 4217)".to_string());
}
if !currency_upper.chars().all(|c| c.is_ascii_alphabetic()) {
return Err("Currency code must contain only letters".to_string());
}
}
// Validate account type if provided
if let Some(account_type) = &self.account_type {
if matches!(account_type, AccountType::Other(s) if s.trim().is_empty()) {
return Err("Account type cannot be empty".to_string());
}
}
// Validate color format if provided
if let Some(color) = &self.color {
if !color.trim().is_empty() && !HEX_COLOR_PATTERN.is_match(color) {
return Err(
"Color must be a valid hex code (e.g., #FF0000 or #FF0000FF)".to_string(),
);
}
}
// Validate initial_balance if provided
if let Some(balance) = &self.initial_balance {
if Decimal::from_str_exact(balance).is_err() {
return Err("Initial balance must be a valid decimal number".to_string());
}
}
Ok(())
}
}
#[derive(Debug, Deserialize, Default)]
pub struct AccountFilter {
pub account_type: Option<AccountType>,
pub include_archived: Option<bool>,
pub limit: Option<u64>,
pub offset: Option<u64>,
}

View File

@@ -0,0 +1,2 @@
pub mod inputs;
pub mod outputs;

View File

@@ -0,0 +1,7 @@
use serde::Serialize;
#[derive(Debug, Serialize)]
pub struct AccountBalance {
pub amount: String,
pub currency: String,
}

View File

@@ -0,0 +1 @@
pub mod service;

View File

@@ -0,0 +1,434 @@
use std::str::FromStr;
use async_trait::async_trait;
use rust_decimal::Decimal;
use sea_orm::{entity::*, query::*};
use serde::{Deserialize, Serialize};
use crate::db::{
connection::ConnectionSource,
entities::{accounts, prelude::*, transactions},
};
use crate::errors::{AppError, CommandResult};
/// Transaction types supported by the system
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TransactionType {
/// Money entering an account (e.g., salary, refunds)
Income,
/// Money leaving an account (e.g., purchases, bills)
Expense,
/// Money transferred into this account from another account
TransferIn,
/// Money transferred out of this account to another account
TransferOut,
}
impl TransactionType {
/// Returns true if this transaction type increases the balance
/// (for standard asset accounts)
pub fn is_balance_increasing(self) -> bool {
matches!(self, TransactionType::Income | TransactionType::TransferIn)
}
/// Returns true if this transaction type decreases the balance
/// (for standard asset accounts)
pub fn is_balance_decreasing(self) -> bool {
matches!(
self,
TransactionType::Expense | TransactionType::TransferOut
)
}
/// Returns true if this is a transfer type
pub fn is_transfer(self) -> bool {
matches!(
self,
TransactionType::TransferIn | TransactionType::TransferOut
)
}
}
impl FromStr for TransactionType {
type Err = AppError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"income" => Ok(TransactionType::Income),
"expense" => Ok(TransactionType::Expense),
"transfer_in" => Ok(TransactionType::TransferIn),
"transfer_out" => Ok(TransactionType::TransferOut),
_ => Err(AppError::Validation(format!(
"Invalid transaction type: {}. Expected one of: income, expense, transfer_in, transfer_out",
s
))),
}
}
}
impl std::fmt::Display for TransactionType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TransactionType::Income => write!(f, "income"),
TransactionType::Expense => write!(f, "expense"),
TransactionType::TransferIn => write!(f, "transfer_in"),
TransactionType::TransferOut => write!(f, "transfer_out"),
}
}
}
/// Check if account type is a liability (credit card, loan)
pub fn is_liability_account(account_type: &str) -> bool {
matches!(account_type.to_lowercase().as_str(), "credit_card" | "loan")
}
/// Apply transaction amount to balance based on account type
pub fn apply_transaction_to_balance(
balance: &mut Decimal,
amount: Decimal,
txn_type: TransactionType,
is_liability: bool,
) {
if is_liability {
// For liability accounts: expenses increase debt, payments decrease debt
match txn_type {
TransactionType::Expense | TransactionType::TransferOut => *balance += amount,
TransactionType::Income | TransactionType::TransferIn => *balance -= amount,
}
} else {
// For asset accounts: normal balance calculation
match txn_type {
TransactionType::Expense | TransactionType::TransferOut => *balance -= amount,
TransactionType::Income | TransactionType::TransferIn => *balance += amount,
}
}
}
/// Result of a balance calculation
#[derive(Debug, Clone)]
pub struct BalanceCalculationResult {
pub balance: Decimal,
pub transaction_count: usize,
}
#[async_trait]
pub trait BalanceCalculator: Send + Sync {
/// Calculate account balance by recalculating from all transactions
/// This is the source of truth for balance calculations
async fn calculate_balance(
&self,
account_id: &str,
conn: &ConnectionSource<'_>,
) -> CommandResult<BalanceCalculationResult>;
/// Calculate historical balance as of a specific date
async fn calculate_historical_balance(
&self,
account_id: &str,
as_of_date: &str,
conn: &ConnectionSource<'_>,
) -> CommandResult<BalanceCalculationResult>;
}
pub struct BalanceCalculatorImpl;
impl BalanceCalculatorImpl {
pub fn new() -> Self {
Self
}
}
impl Default for BalanceCalculatorImpl {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl BalanceCalculator for BalanceCalculatorImpl {
async fn calculate_balance(
&self,
account_id: &str,
conn: &ConnectionSource<'_>,
) -> CommandResult<BalanceCalculationResult> {
// Get account details
let account = Accounts::find_by_id(account_id)
.filter(accounts::Column::IsDeleted.eq(false))
.one(conn)
.await?
.ok_or_else(|| AppError::NotFound(format!("Account {}", account_id)))?;
let is_liability = is_liability_account(&account.account_type);
let mut balance = Decimal::from_str(&account.initial_balance)?;
let mut transaction_count = 0;
// Get all non-deleted transactions for this account
let txns = Transactions::find()
.filter(transactions::Column::AccountId.eq(account_id))
.filter(transactions::Column::IsDeleted.eq(false))
.order_by_asc(transactions::Column::TransactionDate)
.all(conn)
.await?;
for txn in txns {
let amount = Decimal::from_str(&txn.net_amount)?;
let txn_type = TransactionType::from_str(&txn.transaction_type)?;
apply_transaction_to_balance(&mut balance, amount, txn_type, is_liability);
transaction_count += 1;
}
Ok(BalanceCalculationResult {
balance,
transaction_count,
})
}
async fn calculate_historical_balance(
&self,
account_id: &str,
as_of_date: &str,
conn: &ConnectionSource<'_>,
) -> CommandResult<BalanceCalculationResult> {
// Validate date format (YYYY-MM-DD)
if !regex::Regex::new(r"^\d{4}-\d{2}-\d{2}$")
.map_err(|_| AppError::Validation("Invalid regex pattern".to_string()))?
.is_match(as_of_date)
{
return Err(AppError::Validation(
"Invalid date format. Expected YYYY-MM-DD".to_string(),
));
}
// Get account details
let account = Accounts::find_by_id(account_id)
.filter(accounts::Column::IsDeleted.eq(false))
.one(conn)
.await?
.ok_or_else(|| AppError::NotFound(format!("Account {}", account_id)))?;
let is_liability = is_liability_account(&account.account_type);
let mut balance = Decimal::from_str(&account.initial_balance)?;
let mut transaction_count = 0;
// Get transactions up to and including the specified date
let txns = Transactions::find()
.filter(transactions::Column::AccountId.eq(account_id))
.filter(transactions::Column::IsDeleted.eq(false))
.filter(transactions::Column::TransactionDate.lte(as_of_date))
.order_by_asc(transactions::Column::TransactionDate)
.all(conn)
.await?;
for txn in txns {
let amount = Decimal::from_str(&txn.net_amount)?;
let txn_type = TransactionType::from_str(&txn.transaction_type)?;
apply_transaction_to_balance(&mut balance, amount, txn_type, is_liability);
transaction_count += 1;
}
Ok(BalanceCalculationResult {
balance,
transaction_count,
})
}
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn test_transaction_type_from_str() {
assert_eq!(
TransactionType::from_str("income").expect("Valid type"),
TransactionType::Income
);
assert_eq!(
TransactionType::from_str("expense").expect("Valid type"),
TransactionType::Expense
);
assert_eq!(
TransactionType::from_str("transfer_in").expect("Valid type"),
TransactionType::TransferIn
);
assert_eq!(
TransactionType::from_str("transfer_out").expect("Valid type"),
TransactionType::TransferOut
);
// Case insensitive
assert_eq!(
TransactionType::from_str("INCOME").expect("Valid type"),
TransactionType::Income
);
assert_eq!(
TransactionType::from_str("Expense").expect("Valid type"),
TransactionType::Expense
);
// Invalid type
assert!(TransactionType::from_str("invalid").is_err());
assert!(TransactionType::from_str("").is_err());
}
#[test]
fn test_transaction_type_display() {
assert_eq!(TransactionType::Income.to_string(), "income");
assert_eq!(TransactionType::Expense.to_string(), "expense");
assert_eq!(TransactionType::TransferIn.to_string(), "transfer_in");
assert_eq!(TransactionType::TransferOut.to_string(), "transfer_out");
}
#[test]
fn test_transaction_type_helpers() {
assert!(TransactionType::Income.is_balance_increasing());
assert!(TransactionType::TransferIn.is_balance_increasing());
assert!(!TransactionType::Expense.is_balance_increasing());
assert!(!TransactionType::TransferOut.is_balance_increasing());
assert!(!TransactionType::Income.is_balance_decreasing());
assert!(!TransactionType::TransferIn.is_balance_decreasing());
assert!(TransactionType::Expense.is_balance_decreasing());
assert!(TransactionType::TransferOut.is_balance_decreasing());
assert!(TransactionType::TransferIn.is_transfer());
assert!(TransactionType::TransferOut.is_transfer());
assert!(!TransactionType::Income.is_transfer());
assert!(!TransactionType::Expense.is_transfer());
}
#[test]
fn test_is_liability_account() {
assert!(is_liability_account("credit_card"));
assert!(is_liability_account("CREDIT_CARD"));
assert!(is_liability_account("Credit_Card"));
assert!(is_liability_account("loan"));
assert!(is_liability_account("LOAN"));
assert!(!is_liability_account("checking"));
assert!(!is_liability_account("savings"));
assert!(!is_liability_account("cash"));
}
#[test]
fn test_apply_transaction_to_balance_asset() {
let mut balance = Decimal::from(1000);
// Expense decreases balance
apply_transaction_to_balance(
&mut balance,
Decimal::from(100),
TransactionType::Expense,
false,
);
assert_eq!(balance, Decimal::from(900));
// Income increases balance
apply_transaction_to_balance(
&mut balance,
Decimal::from(200),
TransactionType::Income,
false,
);
assert_eq!(balance, Decimal::from(1100));
// Transfer out decreases balance
apply_transaction_to_balance(
&mut balance,
Decimal::from(50),
TransactionType::TransferOut,
false,
);
assert_eq!(balance, Decimal::from(1050));
// Transfer in increases balance
apply_transaction_to_balance(
&mut balance,
Decimal::from(150),
TransactionType::TransferIn,
false,
);
assert_eq!(balance, Decimal::from(1200));
}
#[test]
fn test_apply_transaction_to_balance_liability() {
let mut balance = Decimal::from(-1000); // Negative balance = debt
// Expense increases debt (balance becomes more negative)
apply_transaction_to_balance(
&mut balance,
Decimal::from(100),
TransactionType::Expense,
true,
);
assert_eq!(balance, Decimal::from(-900));
// Income decreases debt (balance becomes less negative)
apply_transaction_to_balance(
&mut balance,
Decimal::from(200),
TransactionType::Income,
true,
);
assert_eq!(balance, Decimal::from(-1100));
// Transfer out increases debt
apply_transaction_to_balance(
&mut balance,
Decimal::from(50),
TransactionType::TransferOut,
true,
);
assert_eq!(balance, Decimal::from(-1050));
// Transfer in decreases debt
apply_transaction_to_balance(
&mut balance,
Decimal::from(150),
TransactionType::TransferIn,
true,
);
assert_eq!(balance, Decimal::from(-1200));
}
#[test]
fn test_transaction_type_serde() {
// Test serialization
assert_eq!(
serde_json::to_string(&TransactionType::Income).expect("Serialize"),
"\"income\""
);
assert_eq!(
serde_json::to_string(&TransactionType::Expense).expect("Serialize"),
"\"expense\""
);
assert_eq!(
serde_json::to_string(&TransactionType::TransferIn).expect("Serialize"),
"\"transfer_in\""
);
assert_eq!(
serde_json::to_string(&TransactionType::TransferOut).expect("Serialize"),
"\"transfer_out\""
);
// Test deserialization
assert_eq!(
serde_json::from_str::<TransactionType>("\"income\"").expect("Deserialize"),
TransactionType::Income
);
assert_eq!(
serde_json::from_str::<TransactionType>("\"expense\"").expect("Deserialize"),
TransactionType::Expense
);
assert_eq!(
serde_json::from_str::<TransactionType>("\"transfer_in\"").expect("Deserialize"),
TransactionType::TransferIn
);
assert_eq!(
serde_json::from_str::<TransactionType>("\"transfer_out\"").expect("Deserialize"),
TransactionType::TransferOut
);
}
}

View File

@@ -0,0 +1,462 @@
// Based on https://github.com/fawazahmed0/exchange-api
// API: https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/{currency}.json
use rust_decimal::Decimal;
use serde::Deserialize;
use std::collections::HashMap;
use super::ExchangeRateAdapter;
use crate::{
errors::{AppError, CommandResult},
services::exchange_rate::adapters::ExchangeRateAdapterInfo,
};
const API_BASE_URL: &str =
"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies";
const ADAPTER_NAME: &str = "exchange_api";
const ADAPTER_DISPLAY_NAME: &str = "Exchange API";
const ADAPTER_DESCRIPTION: &str = "A free API for current and historical exchange rates.";
pub(super) struct ExchangeApiAdapter;
impl ExchangeApiAdapter {
pub fn new() -> Self {
Self
}
}
#[derive(Deserialize, Debug)]
#[cfg_attr(test, derive(serde::Serialize))]
/// {date: "2024-06-01", 'currency_code': {"target_currency_code": rate, ...}}
pub(super) struct ExchangeRateResponse {
pub date: String,
// currency code to exchange rate mapping
#[serde(flatten)]
pub rates: HashMap<String, HashMap<String, f64>>,
}
#[async_trait::async_trait]
impl ExchangeRateAdapter for ExchangeApiAdapter {
fn get_info() -> ExchangeRateAdapterInfo {
ExchangeRateAdapterInfo {
name: ADAPTER_NAME.to_string(),
display_name: ADAPTER_DISPLAY_NAME.to_string(),
description: ADAPTER_DESCRIPTION.to_string(),
}
}
async fn get_supported_currencies(&self, base_currency: Option<&str>) -> Vec<String> {
// The API supports a wide range of currencies. We'll fetch from a common base
// and extract the available currency codes.
// As a fallback, return a comprehensive list of common currencies.
let base = base_currency.unwrap_or("usd").to_lowercase();
match fetch_currency_data(&base).await {
Ok(data) => {
let mut currencies: Vec<String> = data
.rates
.get(&base)
.map(|rates| rates.keys().cloned().collect())
.unwrap_or_default();
currencies.sort();
currencies
}
Err(err) => {
log::error!("Failed to fetch supported currencies: {}", err);
vec![]
}
}
}
async fn get_exchange_rate(
&self,
from_currency: &str,
to_currency: &str,
) -> CommandResult<(Decimal, u64)> {
let from_lower = from_currency.to_lowercase();
let to_lower = to_currency.to_lowercase();
let data = fetch_currency_data(&from_lower).await?;
// Look up the rate for the target currency
if let Some(rates) = data.rates.get(&from_lower) {
if let Some(rate) = rates.get(&to_lower) {
// Convert f64 to Decimal for precision
let decimal_rate = Decimal::from_f64_retain(*rate).ok_or_else(|| {
AppError::Internal("Failed to convert exchange rate to Decimal".to_string())
})?;
// Convert date string to unix timestamp (using the date of the API response)
let timestamp = chrono::NaiveDate::parse_from_str(&data.date, "%Y-%m-%d")
.map_err(|e| {
AppError::Internal(format!("Failed to parse date from API response: {}", e))
})?
.and_hms_opt(0, 0, 0)
.ok_or_else(|| {
AppError::Internal(format!(
"Failed to create datetime from date: {}",
data.date
))
})?
.and_utc()
.timestamp() as u64;
return Ok((decimal_rate, timestamp));
}
}
Err(AppError::NotFound(format!(
"Exchange rate not found for {}/{}",
from_currency, to_currency
)))
}
}
async fn fetch_currency_data(currency: &str) -> CommandResult<ExchangeRateResponse> {
let url = format!("{}/{}.min.json", API_BASE_URL, currency);
let response = reqwest::get(&url)
.await
.map_err(|e| AppError::Internal(format!("Failed to fetch exchange rate: {}", e)))?;
if !response.status().is_success() {
return Err(AppError::Internal(format!(
"Exchange rate API returned status: {}",
response.status()
)));
}
let data: ExchangeRateResponse = response
.json()
.await
.map_err(|e| AppError::Internal(format!("Failed to parse API response: {}", e)))?;
Ok(data)
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
use chrono::Datelike;
use rust_decimal::Decimal;
use std::collections::HashMap;
#[test]
fn test_exchange_api_adapter_new() {
let adapter = ExchangeApiAdapter::new();
// Just verify it can be created - the struct has no fields
let _ = adapter;
}
#[test]
fn test_get_info() {
let info = ExchangeApiAdapter::get_info();
assert_eq!(info.name, ADAPTER_NAME);
assert_eq!(info.display_name, ADAPTER_DISPLAY_NAME);
assert_eq!(info.description, ADAPTER_DESCRIPTION);
}
#[test]
fn test_exchange_rate_response_deserialization() {
let json_data = r#"{
"date": "2024-01-15",
"usd": {
"eur": 0.92,
"gbp": 0.79,
"jpy": 148.50
}
}"#;
let response: ExchangeRateResponse =
serde_json::from_str(json_data).expect("Failed to deserialize ExchangeRateResponse");
assert_eq!(response.date, "2024-01-15");
assert!(response.rates.contains_key("usd"));
let usd_rates = response.rates.get("usd").expect("USD rates not found");
assert_eq!(usd_rates.get("eur"), Some(&0.92));
assert_eq!(usd_rates.get("gbp"), Some(&0.79));
assert_eq!(usd_rates.get("jpy"), Some(&148.50));
}
#[test]
fn test_exchange_rate_response_with_many_currencies() {
let json_data = r#"{
"date": "2024-01-15",
"eur": {
"usd": 1.09,
"gbp": 0.86,
"jpy": 161.20,
"cad": 1.47,
"aud": 1.65,
"chf": 0.94
}
}"#;
let response: ExchangeRateResponse =
serde_json::from_str(json_data).expect("Failed to deserialize ExchangeRateResponse");
assert_eq!(response.date, "2024-01-15");
let eur_rates = response.rates.get("eur").expect("EUR rates not found");
assert_eq!(eur_rates.len(), 6);
assert!(eur_rates.contains_key("usd"));
assert!(eur_rates.contains_key("jpy"));
}
#[test]
fn test_exchange_rate_response_empty_rates() {
let json_data = r#"{
"date": "2024-01-15",
"usd": {}
}"#;
let response: ExchangeRateResponse =
serde_json::from_str(json_data).expect("Failed to deserialize ExchangeRateResponse");
assert_eq!(response.date, "2024-01-15");
let usd_rates = response.rates.get("usd").expect("USD rates not found");
assert!(usd_rates.is_empty());
}
#[test]
fn test_decimal_rate_parsing() {
// Test that we can parse rates with high precision
let rate_str = "0.921234567890";
let rate = Decimal::from_str_exact(rate_str).expect("Failed to parse decimal rate");
assert_eq!(rate.to_string(), rate_str);
// Test with larger rate
let large_rate_str = "148.5012345678";
let large_rate =
Decimal::from_str_exact(large_rate_str).expect("Failed to parse large decimal rate");
assert_eq!(large_rate.to_string(), large_rate_str);
// Test with very small rate
let small_rate_str = "0.00000001";
let small_rate =
Decimal::from_str_exact(small_rate_str).expect("Failed to parse small decimal rate");
assert_eq!(small_rate.to_string(), small_rate_str);
}
#[test]
fn test_date_parsing() {
let date_str = "2024-01-15";
let parsed_date =
chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d").expect("Failed to parse date");
assert_eq!(parsed_date.year(), 2024);
assert_eq!(parsed_date.month(), 1);
assert_eq!(parsed_date.day(), 15);
// Test conversion to datetime
let datetime = parsed_date
.and_hms_opt(0, 0, 0)
.expect("Failed to create datetime")
.and_utc();
assert_eq!(datetime.timestamp() as u64, 1705276800);
}
#[test]
fn test_date_parsing_different_formats() {
// Test year boundary
let date_str = "2024-12-31";
let parsed = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
.expect("Failed to parse year boundary date");
assert_eq!(parsed.year(), 2024);
assert_eq!(parsed.month(), 12);
assert_eq!(parsed.day(), 31);
// Test leap year
let date_str = "2024-02-29";
let parsed = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
.expect("Failed to parse leap year date");
assert_eq!(parsed.month(), 2);
assert_eq!(parsed.day(), 29);
}
#[test]
fn test_exchange_rate_response_serde_roundtrip() {
let mut rates = HashMap::new();
let mut inner_rates = HashMap::new();
inner_rates.insert("eur".to_string(), 0.92_f64);
inner_rates.insert("gbp".to_string(), 0.79_f64);
rates.insert("usd".to_string(), inner_rates);
let response = ExchangeRateResponse {
date: "2024-01-15".to_string(),
rates,
};
let json =
serde_json::to_string(&response).expect("Failed to serialize ExchangeRateResponse");
let deserialized: ExchangeRateResponse =
serde_json::from_str(&json).expect("Failed to deserialize ExchangeRateResponse");
assert_eq!(deserialized.date, response.date);
assert!(deserialized.rates.contains_key("usd"));
}
#[test]
fn test_adapter_implements_exchange_rate_adapter() {
// Compile-time check that ExchangeApiAdapter implements ExchangeRateAdapter
fn check_adapter<T: ExchangeRateAdapter>() {}
check_adapter::<ExchangeApiAdapter>();
}
#[test]
fn test_exchange_rate_response_debug() {
let json_data = r#"{"date": "2024-01-15", "usd": {"eur": 0.92}}"#;
let response: ExchangeRateResponse =
serde_json::from_str(json_data).expect("Failed to deserialize ExchangeRateResponse");
let debug_str = format!("{:?}", response);
assert!(debug_str.contains("2024-01-15"));
assert!(debug_str.contains("usd"));
}
/// Integration tests that call the actual API
/// These tests are marked with #[ignore] to avoid running them in CI
/// Run with: cargo test --package tauri-app -- --ignored
mod integration_tests {
use super::*;
#[tokio::test]
#[ignore = "Calls external API - run manually with: cargo test -- --ignored"]
async fn test_fetch_currency_data_real_api() {
let result = fetch_currency_data("usd").await;
assert!(
result.is_ok(),
"Failed to fetch currency data: {:?}",
result.err()
);
let data = result.expect("Failed to get currency data");
assert!(!data.date.is_empty(), "Date should not be empty");
assert!(!data.rates.is_empty(), "Rates should not be empty");
// Verify USD rates exist
assert!(data.rates.contains_key("usd"), "USD rates should exist");
let usd_rates = data.rates.get("usd").expect("USD rates not found");
// Verify common currencies exist in USD rates
assert!(usd_rates.contains_key("eur"), "EUR rate should exist");
assert!(usd_rates.contains_key("gbp"), "GBP rate should exist");
assert!(usd_rates.contains_key("jpy"), "JPY rate should exist");
}
#[tokio::test]
#[ignore = "Calls external API - run manually with: cargo test -- --ignored"]
async fn test_fetch_currency_data_eur_base() {
let result = fetch_currency_data("eur").await;
assert!(
result.is_ok(),
"Failed to fetch EUR currency data: {:?}",
result.err()
);
let data = result.expect("Failed to get currency data");
assert!(data.rates.contains_key("eur"), "EUR rates should exist");
let eur_rates = data.rates.get("eur").expect("EUR rates not found");
assert!(eur_rates.contains_key("usd"), "USD rate should exist");
}
#[tokio::test]
#[ignore = "Calls external API - run manually with: cargo test -- --ignored"]
async fn test_adapter_get_exchange_rate_real_api() {
let adapter = ExchangeApiAdapter::new();
let result = adapter.get_exchange_rate("usd", "eur").await;
assert!(
result.is_ok(),
"Failed to get exchange rate: {:?}",
result.err()
);
let (rate, timestamp) = result.expect("Failed to get exchange rate");
assert!(rate > Decimal::ZERO, "Rate should be positive");
assert!(timestamp > 0, "Timestamp should be valid");
// USD to EUR should be less than 1
assert!(rate < Decimal::ONE, "USD to EUR rate should be less than 1");
}
#[tokio::test]
#[ignore = "Calls external API - run manually with: cargo test -- --ignored"]
async fn test_adapter_get_supported_currencies_real_api() {
let adapter = ExchangeApiAdapter::new();
let currencies = adapter.get_supported_currencies(Some("usd")).await;
assert!(!currencies.is_empty(), "Should have supported currencies");
// Check for common currencies
assert!(
currencies.contains(&"eur".to_string()),
"Should contain eur"
);
assert!(
currencies.contains(&"gbp".to_string()),
"Should contain gbp"
);
assert!(
currencies.contains(&"jpy".to_string()),
"Should contain jpy"
);
}
#[tokio::test]
#[ignore = "Calls external API - run manually with: cargo test -- --ignored"]
async fn test_adapter_currency_conversion_roundtrip() {
let adapter = ExchangeApiAdapter::new();
// Get USD to EUR rate
let (usd_to_eur, _) = adapter
.get_exchange_rate("usd", "eur")
.await
.expect("Failed to get USD to EUR rate");
// Get EUR to USD rate
let (eur_to_usd, _) = adapter
.get_exchange_rate("eur", "usd")
.await
.expect("Failed to get EUR to USD rate");
// The product of the two rates should be approximately 1
let product = usd_to_eur * eur_to_usd;
let one = Decimal::ONE;
let diff = (product - one).abs();
// Allow for small differences (0.5% tolerance) due to API rate variations
assert!(
diff < Decimal::from_str_exact("0.005").expect("Failed to parse tolerance"),
"Roundtrip conversion should be approximately 1, got: {}",
product
);
}
#[tokio::test]
#[ignore = "Calls external API - run manually with: cargo test -- --ignored"]
async fn test_real_rate_decimal_parsing() {
let result = fetch_currency_data("usd").await;
assert!(result.is_ok());
let data = result.expect("Failed to get currency data");
let usd_rates = data.rates.get("usd").expect("USD rates not found");
// Verify all rates can be converted to Decimal
for (currency, rate_f64) in usd_rates {
let rate = Decimal::from_f64_retain(*rate_f64);
assert!(
rate.is_some(),
"Failed to convert rate for {}: {}",
currency,
rate_f64
);
let rate = rate.expect("Decimal conversion failed");
assert!(
rate > Decimal::ZERO,
"Rate for {} should be positive",
currency
);
}
}
}
}

View File

@@ -0,0 +1,472 @@
// Based on https://www.exchangerate-api.com/docs/free
// API: https://api.exchangerate-api.com/v4/latest/{base_currency}
use rust_decimal::Decimal;
use serde::Deserialize;
use std::collections::HashMap;
use super::ExchangeRateAdapter;
use crate::{
errors::{AppError, CommandResult},
services::exchange_rate::adapters::ExchangeRateAdapterInfo,
};
const API_BASE_URL: &str = "https://api.exchangerate-api.com/v4/latest";
const ADAPTER_NAME: &str = "exchange_rate_api";
const ADAPTER_DISPLAY_NAME: &str = "ExchangeRate-API";
const ADAPTER_DESCRIPTION: &str = "A free API for current and historical exchange rates. {\"tag\": \"<a href='https://www.exchangerate-api.com'>Rates By Exchange Rate API</a>\"}";
pub(super) struct ExchangeRateApiAdapter;
impl ExchangeRateApiAdapter {
pub fn new() -> Self {
Self
}
}
#[derive(Deserialize, Debug)]
#[cfg_attr(test, derive(serde::Serialize))]
pub(super) struct ExchangeRateResponse {
#[serde(rename = "base")]
pub base_code: String,
#[serde(rename = "time_last_updated")]
pub time_last_update_unix: u64,
// currency code to exchange rate mapping
pub rates: HashMap<String, f64>,
}
#[async_trait::async_trait]
impl ExchangeRateAdapter for ExchangeRateApiAdapter {
fn get_info() -> ExchangeRateAdapterInfo {
ExchangeRateAdapterInfo {
name: ADAPTER_NAME.to_string(),
display_name: ADAPTER_DISPLAY_NAME.to_string(),
description: ADAPTER_DESCRIPTION.to_string(),
}
}
async fn get_supported_currencies(&self, base_currency: Option<&str>) -> Vec<String> {
// Fetch supported currencies by getting rates for USD
let base = base_currency.unwrap_or("USD").to_uppercase();
match fetch_exchange_rates(&base).await {
Ok(data) => {
let mut currencies: Vec<String> = data.rates.keys().cloned().collect();
currencies.sort();
currencies
}
Err(err) => {
log::error!("Failed to fetch supported currencies: {}", err);
vec![]
}
}
}
async fn get_exchange_rate(
&self,
from_currency: &str,
to_currency: &str,
) -> CommandResult<(Decimal, u64)> {
let from_upper = from_currency.to_uppercase();
let to_upper = to_currency.to_uppercase();
let data = fetch_exchange_rates(&from_upper).await?;
// Look up the rate for the target currency
if let Some(rate) = data.rates.get(&to_upper) {
let decimal_rate = Decimal::from_f64_retain(*rate).ok_or_else(|| {
AppError::Internal("Failed to convert exchange rate to Decimal".to_string())
})?;
Ok((decimal_rate, data.time_last_update_unix))
} else {
Err(AppError::NotFound(format!(
"Exchange rate not found for {}/{}",
from_currency, to_currency
)))
}
}
}
async fn fetch_exchange_rates(base_currency: &str) -> CommandResult<ExchangeRateResponse> {
let url = format!("{}/{}", API_BASE_URL, base_currency.to_uppercase());
let response = reqwest::get(&url)
.await
.map_err(|e| AppError::Internal(format!("Failed to fetch exchange rates: {}", e)))?;
if !response.status().is_success() {
return Err(AppError::Internal(format!(
"Exchange rate API returned status: {}",
response.status()
)));
}
let data: ExchangeRateResponse = response
.json()
.await
.map_err(|e| AppError::Internal(format!("Failed to parse API response: {}", e)))?;
Ok(data)
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
use chrono::Datelike;
use rust_decimal::Decimal;
use std::collections::HashMap;
#[test]
fn test_exchange_rate_api_adapter_new() {
let adapter = ExchangeRateApiAdapter::new();
// Just verify it can be created - the struct has no fields
let _ = adapter;
}
#[test]
fn test_get_info() {
let info = ExchangeRateApiAdapter::get_info();
assert_eq!(info.name, ADAPTER_NAME);
assert_eq!(info.display_name, ADAPTER_DISPLAY_NAME);
assert_eq!(info.description, ADAPTER_DESCRIPTION);
}
#[test]
fn test_exchange_rate_response_deserialization() {
let json_data = r#"{
"base": "USD",
"time_last_updated": 1704067200,
"rates": {
"EUR": 0.92,
"GBP": 0.79,
"JPY": 148.50
}
}"#;
let response: ExchangeRateResponse =
serde_json::from_str(json_data).expect("Failed to deserialize JSON");
assert_eq!(response.base_code, "USD");
assert_eq!(response.time_last_update_unix, 1704067200);
assert_eq!(response.rates.len(), 3);
assert_eq!(response.rates.get("EUR"), Some(&0.92_f64));
assert_eq!(response.rates.get("GBP"), Some(&0.79_f64));
assert_eq!(response.rates.get("JPY"), Some(&148.50_f64));
}
#[test]
fn test_exchange_rate_response_with_many_currencies() {
let json_data = r#"{
"base": "EUR",
"time_last_updated": 1704067200,
"rates": {
"USD": 1.09,
"GBP": 0.86,
"JPY": 161.20,
"CAD": 1.47,
"AUD": 1.65,
"CHF": 0.94
}
}"#;
let response: ExchangeRateResponse =
serde_json::from_str(json_data).expect("Failed to deserialize JSON");
assert_eq!(response.base_code, "EUR");
assert_eq!(response.rates.len(), 6);
assert!(response.rates.contains_key("USD"));
assert!(response.rates.contains_key("JPY"));
}
#[test]
fn test_exchange_rate_response_empty_rates() {
let json_data = r#"{
"base": "USD",
"time_last_updated": 1704067200,
"rates": {}
}"#;
let response: ExchangeRateResponse =
serde_json::from_str(json_data).expect("Failed to deserialize JSON");
assert_eq!(response.base_code, "USD");
assert!(response.rates.is_empty());
}
#[test]
fn test_decimal_precision_from_f64() {
// Test that Decimal can represent rates from f64 precisely
let f64_rate: f64 = 0.9215;
let decimal_rate =
Decimal::from_f64_retain(f64_rate).expect("Failed to retain f64 as Decimal");
assert!(decimal_rate > Decimal::ZERO);
// Test with larger rate
let large_f64: f64 = 148.50;
let large_decimal =
Decimal::from_f64_retain(large_f64).expect("Failed to retain f64 as Decimal");
assert!(large_decimal > Decimal::ONE);
}
#[test]
fn test_currency_case_normalization() {
// The adapter should normalize currencies to uppercase
let eur_lower = "eur";
let eur_upper = "EUR";
let eur_mixed = "Eur";
assert_eq!(eur_lower.to_uppercase(), "EUR");
assert_eq!(eur_upper.to_uppercase(), "EUR");
assert_eq!(eur_mixed.to_uppercase(), "EUR");
}
#[test]
fn test_timestamp_unix_conversion() {
let timestamp: u64 = 1704067200; // 2024-01-01 00:00:00 UTC
// Verify the timestamp can be converted to DateTime
let dt = chrono::DateTime::from_timestamp(timestamp as i64, 0);
assert!(dt.is_some());
let dt = dt.expect("DateTime conversion failed");
assert_eq!(dt.year(), 2024);
assert_eq!(dt.month(), 1);
assert_eq!(dt.day(), 1);
}
#[test]
fn test_exchange_rate_response_serde_roundtrip() {
let mut rates = HashMap::new();
rates.insert("EUR".to_string(), 0.92_f64);
rates.insert("GBP".to_string(), 0.79_f64);
let response = ExchangeRateResponse {
base_code: "USD".to_string(),
time_last_update_unix: 1704067200,
rates,
};
let json = serde_json::to_string(&response).expect("Failed to serialize JSON");
let deserialized: ExchangeRateResponse =
serde_json::from_str(&json).expect("Failed to deserialize JSON");
assert_eq!(deserialized.base_code, response.base_code);
assert_eq!(
deserialized.time_last_update_unix,
response.time_last_update_unix
);
assert_eq!(deserialized.rates.len(), response.rates.len());
}
#[test]
fn test_api_base_url_constant() {
assert!(API_BASE_URL.contains("exchangerate-api.com"));
assert!(API_BASE_URL.contains("v4/latest"));
}
#[test]
fn test_exchange_rate_response_debug() {
let json_data = r#"{
"base": "USD",
"time_last_updated": 1704067200,
"rates": {"EUR": 0.92}
}"#;
let response: ExchangeRateResponse =
serde_json::from_str(json_data).expect("Failed to deserialize JSON");
let debug_str = format!("{:?}", response);
assert!(debug_str.contains("USD"));
assert!(debug_str.contains("1704067200"));
}
#[test]
fn test_decimal_roundtrip_conversion() {
// Test that Decimal -> String -> Decimal preserves value
let original =
Decimal::from_f64_retain(1.23456789).expect("Failed to retain f64 as Decimal");
let as_string = original.to_string();
let parsed = Decimal::from_str_exact(&as_string).expect("Failed to parse rate string");
assert_eq!(original, parsed);
}
#[test]
fn test_rate_lookups() {
let mut rates = HashMap::new();
rates.insert("USD".to_string(), Decimal::ONE);
rates.insert(
"EUR".to_string(),
Decimal::from_f64_retain(0.92).expect("Failed to retain f64 as Decimal"),
);
rates.insert(
"GBP".to_string(),
Decimal::from_f64_retain(0.79).expect("Failed to retain f64 as Decimal"),
);
// Test that we can look up rates
assert!(rates.contains_key("USD"));
assert!(rates.contains_key("EUR"));
assert!(!rates.contains_key("JPY"));
// Test getting specific rate
let usd_rate = rates.get("USD").expect("USD rate not found");
assert_eq!(*usd_rate, Decimal::ONE);
}
#[test]
fn test_exchange_rate_response_with_special_currencies() {
// Some currencies might have special handling
let json_data = r#"{
"base": "USD",
"time_last_updated": 1704067200,
"rates": {
"XBT": 0.000023,
"XAU": 0.00042
}
}"#;
let response: ExchangeRateResponse =
serde_json::from_str(json_data).expect("Failed to deserialize JSON");
assert_eq!(response.rates.len(), 2);
assert!(response.rates.contains_key("XBT"));
assert!(response.rates.contains_key("XAU"));
}
#[test]
fn test_decimal_from_str_exact_various_rates() {
let test_cases = vec![
"1.0",
"0.5",
"0.1234567890",
"100.00",
"0.000001",
"999999.999999",
];
for rate_str in test_cases {
let result = Decimal::from_str_exact(rate_str);
assert!(result.is_ok(), "Failed to parse: {}", rate_str);
let decimal = result.expect("Decimal parsing failed");
assert_eq!(decimal.to_string(), rate_str);
}
}
/// Integration tests that call the actual API
/// These tests are marked with #[ignore] to avoid running them in CI
/// Run with: cargo test --package tauri-app -- --ignored
mod integration_tests {
use super::*;
#[tokio::test]
#[ignore = "Calls external API - run manually with: cargo test -- --ignored"]
async fn test_fetch_exchange_rates_real_api() {
let result = fetch_exchange_rates("USD").await;
assert!(
result.is_ok(),
"Failed to fetch exchange rates: {:?}",
result.err()
);
let data = result.expect("Failed to get exchange rates");
assert_eq!(data.base_code, "USD");
assert!(!data.rates.is_empty(), "Rates should not be empty");
assert!(data.time_last_update_unix > 0, "Timestamp should be valid");
// Verify common currencies exist
assert!(data.rates.contains_key("EUR"), "EUR rate should exist");
assert!(data.rates.contains_key("GBP"), "GBP rate should exist");
assert!(data.rates.contains_key("JPY"), "JPY rate should exist");
}
#[tokio::test]
#[ignore = "Calls external API - run manually with: cargo test -- --ignored"]
async fn test_fetch_exchange_rates_eur_base() {
let result = fetch_exchange_rates("EUR").await;
assert!(
result.is_ok(),
"Failed to fetch EUR-based rates: {:?}",
result.err()
);
let data = result.expect("Failed to get exchange rates");
assert_eq!(data.base_code, "EUR");
assert!(data.rates.contains_key("USD"), "USD rate should exist");
}
#[tokio::test]
#[ignore = "Calls external API - run manually with: cargo test -- --ignored"]
async fn test_adapter_get_exchange_rate_real_api() {
let adapter = ExchangeRateApiAdapter::new();
let result = adapter.get_exchange_rate("USD", "EUR").await;
assert!(
result.is_ok(),
"Failed to get exchange rate: {:?}",
result.err()
);
let (rate, timestamp) = result.expect("Failed to get exchange rate");
assert!(rate > Decimal::ZERO, "Rate should be positive");
assert!(timestamp > 0, "Timestamp should be valid");
// USD to EUR should be less than 1
assert!(rate < Decimal::ONE, "USD to EUR rate should be less than 1");
}
#[tokio::test]
#[ignore = "Calls external API - run manually with: cargo test -- --ignored"]
async fn test_adapter_get_supported_currencies_real_api() {
let adapter = ExchangeRateApiAdapter::new();
let currencies = adapter.get_supported_currencies(Some("USD")).await;
assert!(!currencies.is_empty(), "Should have supported currencies");
// Check for common currencies
assert!(
currencies.contains(&"USD".to_string()),
"Should contain USD"
);
assert!(
currencies.contains(&"EUR".to_string()),
"Should contain EUR"
);
assert!(
currencies.contains(&"GBP".to_string()),
"Should contain GBP"
);
assert!(
currencies.contains(&"JPY".to_string()),
"Should contain JPY"
);
}
#[tokio::test]
#[ignore = "Calls external API - run manually with: cargo test -- --ignored"]
async fn test_adapter_currency_conversion_roundtrip() {
let adapter = ExchangeRateApiAdapter::new();
// Get USD to EUR rate
let (usd_to_eur, _) = adapter
.get_exchange_rate("USD", "EUR")
.await
.expect("Failed to get USD to EUR rate");
// Get EUR to USD rate
let (eur_to_usd, _) = adapter
.get_exchange_rate("EUR", "USD")
.await
.expect("Failed to get EUR to USD rate");
// The product of the two rates should be approximately 1
let product = usd_to_eur * eur_to_usd;
let one = Decimal::ONE;
let diff = (product - one).abs();
// Allow for small floating point differences (0.5% tolerance for real-world rates)
assert!(
diff < Decimal::from_str_exact("0.005").expect("Failed to parse tolerance"),
"Roundtrip conversion should be approximately 1, got: {}",
product
);
}
}
}

View File

@@ -0,0 +1,233 @@
use std::sync::Arc;
use crate::errors::CommandResult;
pub(super) mod exchange_api;
pub(super) mod exchange_rate_api;
#[derive(Debug, Clone, serde::Serialize)]
pub struct ExchangeRateAdapterInfo {
pub name: String,
pub display_name: String,
pub description: String,
}
#[async_trait::async_trait]
pub trait ExchangeRateAdapter {
fn get_info() -> ExchangeRateAdapterInfo
where
Self: Sized;
/// Get the exchange rate from `from_currency` to `to_currency`.
/// Returns the exchange rate and the timestamp of the last update.
async fn get_exchange_rate(
&self,
from_currency: &str,
to_currency: &str,
) -> CommandResult<(rust_decimal::Decimal, u64)>;
async fn get_supported_currencies(&self, base_currency: Option<&str>) -> Vec<String>;
}
pub fn get_default_adapter() -> Arc<dyn ExchangeRateAdapter + Send + Sync> {
Arc::new(exchange_api::ExchangeApiAdapter::new())
}
pub fn get_adapter_info() -> Vec<ExchangeRateAdapterInfo> {
vec![
exchange_api::ExchangeApiAdapter::get_info(),
exchange_rate_api::ExchangeRateApiAdapter::get_info(),
]
}
pub fn get_adapter_by_name(name: &str) -> Option<Arc<dyn ExchangeRateAdapter + Send + Sync>> {
match name {
"exchange_api" => Some(Arc::new(exchange_api::ExchangeApiAdapter::new())),
"exchange_rate_api" => Some(Arc::new(exchange_rate_api::ExchangeRateApiAdapter::new())),
_ => {
log::warn!("Unknown adapter name: {}", name);
None
}
}
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
use chrono::Datelike;
use rust_decimal::Decimal;
#[test]
fn test_get_adapter_info_returns_both_adapters() {
let names = get_adapter_info()
.iter()
.map(|info| info.name.clone())
.collect::<Vec<String>>();
assert_eq!(names.len(), 2);
assert!(names.contains(&"exchange_api".to_string()));
assert!(names.contains(&"exchange_rate_api".to_string()));
}
#[test]
fn test_get_adapter_by_name_exchange_api() {
let adapter = get_adapter_by_name("exchange_api");
assert!(adapter.is_some());
}
#[test]
fn test_get_adapter_by_name_exchange_rate_api() {
let adapter = get_adapter_by_name("exchange_rate_api");
assert!(adapter.is_some());
}
#[test]
fn test_get_adapter_by_name_unknown() {
let adapter = get_adapter_by_name("unknown_adapter");
assert!(adapter.is_none());
}
#[test]
fn test_get_adapter_by_name_empty() {
let adapter = get_adapter_by_name("");
assert!(adapter.is_none());
}
#[test]
fn test_exchange_api_adapter_new() {
let _adapter = exchange_api::ExchangeApiAdapter::new();
// Just verify it can be created
}
#[test]
fn test_exchange_rate_api_adapter_new() {
let _adapter = exchange_rate_api::ExchangeRateApiAdapter::new();
// Just verify it can be created
}
// Test the response struct parsing for exchange_api
#[test]
fn test_exchange_api_response_deserialization() {
use exchange_api::ExchangeRateResponse;
let json_data = r#"{
"date": "2024-01-01",
"usd": {
"eur": 0.85,
"gbp": 0.73
}
}"#;
let response: ExchangeRateResponse =
serde_json::from_str(json_data).expect("Failed to deserialize ExchangeRateResponse");
assert_eq!(response.date, "2024-01-01");
assert!(response.rates.contains_key("usd"));
let usd_rates = response.rates.get("usd").expect("USD rates not found");
assert_eq!(usd_rates.get("eur"), Some(&0.85_f64));
assert_eq!(usd_rates.get("gbp"), Some(&0.73_f64));
}
// Test the response struct parsing for exchange_rate_api
#[test]
fn test_exchange_rate_api_response_deserialization() {
use exchange_rate_api::ExchangeRateResponse;
let json_data = r#"{
"base": "USD",
"time_last_updated": 1704067200,
"rates": {
"EUR": 0.85,
"GBP": 0.73,
"JPY": 150.5
}
}"#;
let response: ExchangeRateResponse =
serde_json::from_str(json_data).expect("Failed to deserialize ExchangeRateResponse");
assert_eq!(response.base_code, "USD");
assert_eq!(response.time_last_update_unix, 1704067200);
assert!(response.rates.contains_key("EUR"));
assert_eq!(response.rates.get("EUR"), Some(&0.85_f64));
}
#[test]
fn test_exchange_api_response_with_empty_rates() {
use exchange_api::ExchangeRateResponse;
let json_data = r#"{
"date": "2024-01-01",
"usd": {}
}"#;
let response: ExchangeRateResponse =
serde_json::from_str(json_data).expect("Failed to deserialize ExchangeRateResponse");
assert_eq!(response.date, "2024-01-01");
assert!(
response
.rates
.get("usd")
.expect("USD rates not found")
.is_empty()
);
}
#[test]
fn test_exchange_rate_api_response_with_many_currencies() {
use exchange_rate_api::ExchangeRateResponse;
let json_data = r#"{
"base": "EUR",
"time_last_updated": 1704067200,
"rates": {
"USD": 1.18,
"GBP": 0.86,
"JPY": 160.2,
"CAD": 1.58,
"AUD": 1.62,
"CHF": 0.94
}
}"#;
let response: ExchangeRateResponse =
serde_json::from_str(json_data).expect("Failed to deserialize ExchangeRateResponse");
assert_eq!(response.rates.len(), 6);
assert!(response.rates.contains_key("USD"));
assert!(response.rates.contains_key("JPY"));
}
#[test]
fn test_decimal_parsing_from_api_responses() {
// Test parsing string rates from exchange_api format
let rate_str = "0.851234567890";
let rate = Decimal::from_str_exact(rate_str).expect("Failed to parse decimal rate");
assert_eq!(rate.to_string(), rate_str);
// Test precision is maintained
let precise_rate =
Decimal::from_f64_retain(0.12345678901234).expect("Failed to parse precise rate");
assert!(precise_rate > Decimal::ZERO);
}
#[test]
fn test_exchange_api_response_date_format() {
use exchange_api::ExchangeRateResponse;
let json_data = r#"{"date": "2024-12-31", "usd": {}}"#;
let response: ExchangeRateResponse =
serde_json::from_str(json_data).expect("Failed to deserialize ExchangeRateResponse");
assert_eq!(response.date, "2024-12-31");
// Verify date can be parsed
let parsed_date = chrono::NaiveDate::parse_from_str(&response.date, "%Y-%m-%d")
.expect("Failed to parse date");
assert!(parsed_date.year() == 2024);
}
#[test]
fn test_adapter_trait_object_safety() {
// Verify that ExchangeRateAdapter can be used as a trait object
let _: Option<Arc<dyn ExchangeRateAdapter + Send + Sync>> = None;
}
}

View File

@@ -0,0 +1,5 @@
mod adapters;
pub mod service;
// reexporting for easier access
pub use adapters::ExchangeRateAdapterInfo;

View File

@@ -0,0 +1,565 @@
use std::sync::Arc;
use chrono::Utc;
use rust_decimal::Decimal;
use sea_orm::{DatabaseConnection, Set, entity::*};
use tokio::sync::RwLock;
use crate::{
db::entities::{exchange_rates, prelude::*},
errors::{AppError, CommandResult},
services::{
ServiceTrait, exchange_rate::adapters::ExchangeRateAdapterInfo,
settings::service::SettingsService,
},
};
const PREFIX: &str = "exchange_adapter";
const EXCHANGE_ADAPTER_SETTING_KEY: &str = "selected_adapter";
const DEFAULT_ADAPTER_NAME: &str = "exchange_api";
#[async_trait::async_trait]
pub trait ExchangeRateService: ServiceTrait {
async fn get_exchange_rate(
&self,
from_currency: &str,
to_currency: &str,
) -> CommandResult<Decimal>;
async fn get_supported_currencies(&self) -> Vec<String>;
async fn get_available_adapters(&self) -> Vec<ExchangeRateAdapterInfo> {
super::adapters::get_adapter_info()
}
async fn set_adapter(&self, adapter_name: &str) -> CommandResult<()>;
async fn get_current_adapter(&self) -> CommandResult<String>;
}
pub struct ExchangeRateServiceImpl {
db: DatabaseConnection,
settings_service: Arc<crate::services::settings::service::SettingsServiceImpl>,
adapter: RwLock<Arc<dyn super::adapters::ExchangeRateAdapter + Send + Sync>>,
supported_currencies_cache: RwLock<Option<(Vec<String>, chrono::DateTime<chrono::Utc>)>>,
}
impl ExchangeRateServiceImpl {
pub async fn new(
db: DatabaseConnection,
settings_service: Arc<crate::services::settings::service::SettingsServiceImpl>,
) -> Self {
let adapter_name = settings_service
.get_setting(
&Self::get_settings_key(EXCHANGE_ADAPTER_SETTING_KEY),
DEFAULT_ADAPTER_NAME,
)
.await;
let adapter = super::adapters::get_adapter_by_name(&adapter_name).unwrap_or_else(|| {
log::warn!(
"Adapter '{}' not found, falling back to default adapter '{}'",
adapter_name,
DEFAULT_ADAPTER_NAME
);
super::adapters::get_default_adapter()
});
Self {
db,
settings_service,
adapter: RwLock::new(adapter),
supported_currencies_cache: RwLock::new(None),
}
}
fn get_settings_key(key: &str) -> String {
format!("{}:{}", PREFIX, key)
}
async fn store_exchange_rate(
&self,
from_currency: &str,
to_currency: &str,
rate: Decimal,
timestamp: u64,
) -> CommandResult<()> {
let today = Utc::now().date_naive().to_string();
let fetched_at = chrono::DateTime::from_timestamp(timestamp as i64, 0)
.ok_or_else(|| AppError::InvalidData("Invalid timestamp".into()))?
.naive_utc();
let active_model = exchange_rates::ActiveModel {
from_currency: Set(from_currency.to_string()),
to_currency: Set(to_currency.to_string()),
date: Set(today),
rate: Set(rate.to_string()),
source: Set(None),
fetched_at: Set(Some(fetched_at)),
};
// Use upsert to handle conflicts on the composite primary key
let _ = ExchangeRates::insert(active_model)
.on_conflict(
sea_orm::sea_query::OnConflict::new()
.update_column(exchange_rates::Column::Rate)
.update_column(exchange_rates::Column::FetchedAt)
.to_owned(),
)
.exec(&self.db)
.await?;
Ok(())
}
async fn get_cached_exchange_rate(
&self,
from_currency: &str,
to_currency: &str,
) -> CommandResult<Option<(Decimal, chrono::DateTime<chrono::Utc>)>> {
let today = Utc::now().date_naive().to_string();
let result =
ExchangeRates::find_by_id((from_currency.to_string(), to_currency.to_string(), today))
.one(&self.db)
.await?;
if let Some(model) = result {
let rate = Decimal::from_str_exact(&model.rate)
.ok()
.ok_or_else(|| AppError::InvalidData("Failed to parse exchange rate".into()))?;
let timestamp = model
.fetched_at
.ok_or_else(|| AppError::InvalidData("Missing fetched_at timestamp".into()))?;
// Convert NaiveDateTime to DateTime<Utc>
let timestamp_utc = chrono::DateTime::from_naive_utc_and_offset(timestamp, chrono::Utc);
Ok(Some((rate, timestamp_utc)))
} else {
Ok(None)
}
}
}
#[async_trait::async_trait]
impl ServiceTrait for ExchangeRateServiceImpl {
async fn on_app_start(&self) -> CommandResult<()> {
// Ensure the default adapter is set in settings if not already set
let current_adapter = self
.settings_service
.get_setting(
&Self::get_settings_key(EXCHANGE_ADAPTER_SETTING_KEY),
DEFAULT_ADAPTER_NAME,
)
.await;
if current_adapter.is_empty() {
self.settings_service
.update_setting(
Self::get_settings_key(EXCHANGE_ADAPTER_SETTING_KEY),
DEFAULT_ADAPTER_NAME.to_string(),
None,
)
.await?;
}
Ok(())
}
}
#[async_trait::async_trait]
impl ExchangeRateService for ExchangeRateServiceImpl {
async fn get_exchange_rate(
&self,
from_currency: &str,
to_currency: &str,
) -> CommandResult<Decimal> {
match self
.get_cached_exchange_rate(from_currency, to_currency)
.await
{
Ok(Some((cached_rate, timestamp))) => {
// If cached rate is less than 12 hours old and is same date, return it
if chrono::Utc::now() - timestamp < chrono::Duration::hours(12)
&& timestamp.date_naive() == chrono::Utc::now().date_naive()
{
return Ok(cached_rate);
}
}
Ok(None) => {
// No cached rate, will fetch new rate
}
Err(e) => {
// Log the error but continue to fetch new rate
log::error!("Error fetching cached exchange rate: {}", e);
}
}
let adapter = self.adapter.read().await;
let (rate, timestamp) = adapter
.get_exchange_rate(from_currency, to_currency)
.await?;
// ignore errors when storing exchange rate, since we can still return the fetched rate
let _ = self
.store_exchange_rate(from_currency, to_currency, rate, timestamp)
.await
.inspect_err(|e| log::error!("Error storing exchange rate: {}", e));
Ok(rate)
}
async fn get_supported_currencies(&self) -> Vec<String> {
let cache = self.supported_currencies_cache.read().await;
if let Some((cached_currencies, timestamp)) = &*cache {
// If cached currencies are less than 12 hours old and is same date, return them
if *timestamp > chrono::Utc::now() - chrono::Duration::hours(12)
&& timestamp.date_naive() == chrono::Utc::now().date_naive()
{
return cached_currencies.clone();
}
}
drop(cache);
let settings = self.settings_service.get_settings(None).await;
let base_currency = match settings {
Ok(s) => s.base_currency,
Err(_) => return vec![], // If we can't get settings, return empty list
};
let adapter = self.adapter.read().await;
let currencies = adapter.get_supported_currencies(Some(&base_currency)).await;
*self.supported_currencies_cache.write().await =
Some((currencies.clone(), chrono::Utc::now()));
currencies
}
async fn set_adapter(&self, adapter_name: &str) -> CommandResult<()> {
if let Some(adapter) = super::adapters::get_adapter_by_name(adapter_name) {
*self.adapter.write().await = adapter;
// Clear the supported currencies cache when adapter changes
*self.supported_currencies_cache.write().await = None;
self.settings_service
.update_setting(
Self::get_settings_key(EXCHANGE_ADAPTER_SETTING_KEY),
adapter_name.to_string(),
None,
)
.await?;
Ok(())
} else {
Err(crate::errors::AppError::NotFound(format!(
"Adapter '{}' not found",
adapter_name
)))
}
}
async fn get_current_adapter(&self) -> CommandResult<String> {
let settings = self
.settings_service
.get_setting(
Self::get_settings_key(EXCHANGE_ADAPTER_SETTING_KEY).as_str(),
"",
)
.await;
if settings.is_empty() {
return Err(crate::errors::AppError::NotFound(
"No adapter selected".to_string(),
));
}
Ok(settings)
}
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
use crate::errors::AppError;
use crate::services::exchange_rate::adapters::{
ExchangeRateAdapter, get_adapter_by_name, get_adapter_info,
};
use chrono::NaiveDateTime;
// Mock adapter for testing
struct MockAdapter {
_name: String,
rate_to_return: Option<(Decimal, u64)>,
currencies_to_return: Vec<String>,
}
impl MockAdapter {
fn new(name: &str) -> Self {
Self {
_name: name.to_string(),
rate_to_return: Some((
Decimal::from_f64_retain(0.85).expect("Failed to retain f64 as Decimal"),
1704067200,
)), // 2024-01-01 00:00:00 UTC
currencies_to_return: vec!["USD".to_string(), "EUR".to_string(), "GBP".to_string()],
}
}
fn with_rate(mut self, rate: Decimal, timestamp: u64) -> Self {
self.rate_to_return = Some((rate, timestamp));
self
}
fn with_currencies(mut self, currencies: Vec<String>) -> Self {
self.currencies_to_return = currencies;
self
}
fn with_error(mut self) -> Self {
self.rate_to_return = None;
self
}
}
#[async_trait::async_trait]
impl ExchangeRateAdapter for MockAdapter {
fn get_info() -> ExchangeRateAdapterInfo {
ExchangeRateAdapterInfo {
name: "mock_adapter".to_string(),
display_name: "Mock Adapter".to_string(),
description: "A mock adapter for testing.".to_string(),
}
}
async fn get_exchange_rate(
&self,
_from_currency: &str,
_to_currency: &str,
) -> CommandResult<(Decimal, u64)> {
match self.rate_to_return {
Some((rate, timestamp)) => Ok((rate, timestamp)),
None => Err(AppError::Internal("Mock error".to_string())),
}
}
async fn get_supported_currencies(&self, _base_currency: Option<&str>) -> Vec<String> {
self.currencies_to_return.clone()
}
}
// Note: Creating DbService with mock connections requires special handling
// due to type constraints. The tests below use a simpler approach.
#[test]
fn test_get_adapter_info() {
let infos = get_adapter_info();
assert_eq!(infos.len(), 2);
assert_eq!(infos[0].name, "exchange_api");
assert_eq!(infos[1].name, "exchange_rate_api");
}
#[test]
fn test_get_adapter_by_name_exchange_api() {
let adapter = get_adapter_by_name("exchange_api");
assert!(adapter.is_some());
}
#[test]
fn test_get_adapter_by_name_exchange_rate_api() {
let adapter = get_adapter_by_name("exchange_rate_api");
assert!(adapter.is_some());
}
#[test]
fn test_get_adapter_by_name_invalid() {
let adapter = get_adapter_by_name("invalid_adapter");
assert!(adapter.is_none());
}
#[tokio::test]
async fn test_mock_adapter_get_exchange_rate() {
let adapter = MockAdapter::new("test");
let result = adapter.get_exchange_rate("USD", "EUR").await;
assert!(result.is_ok());
let (rate, timestamp) = result.expect("Result is None");
assert_eq!(
rate,
Decimal::from_f64_retain(0.85).expect("Failed to retain f64 as Decimal")
);
assert_eq!(timestamp, 1704067200);
}
#[tokio::test]
async fn test_mock_adapter_get_supported_currencies() {
let adapter = MockAdapter::new("test");
let currencies = adapter.get_supported_currencies(Some("USD")).await;
assert_eq!(currencies.len(), 3);
assert!(currencies.contains(&"USD".to_string()));
assert!(currencies.contains(&"EUR".to_string()));
assert!(currencies.contains(&"GBP".to_string()));
}
#[tokio::test]
async fn test_mock_adapter_with_custom_rate() {
let adapter = MockAdapter::new("test").with_rate(
Decimal::from_f64_retain(1.25).expect("Failed to retain f64 as Decimal"),
1704153600,
);
let result = adapter.get_exchange_rate("USD", "CAD").await;
assert!(result.is_ok());
let (rate, timestamp) = result.expect("Result is None");
assert_eq!(
rate,
Decimal::from_f64_retain(1.25).expect("Failed to retain f64 as Decimal")
);
assert_eq!(timestamp, 1704153600);
}
#[tokio::test]
async fn test_mock_adapter_with_custom_currencies() {
let custom_currencies = vec!["JPY".to_string(), "CNY".to_string()];
let adapter = MockAdapter::new("test").with_currencies(custom_currencies.clone());
let currencies = adapter.get_supported_currencies(None).await;
assert_eq!(currencies, custom_currencies);
}
// Note: The following tests require proper DbService mocking which is complex
// due to the Arc<DbService> type. These are integration test patterns.
#[tokio::test]
async fn test_store_exchange_rate() {
// Create the exchange rate service with mock adapter
let mock_adapter = Arc::new(MockAdapter::new("test"));
// We need to use a real DbService, so we'll test the store_exchange_rate logic indirectly
// through get_exchange_rate when no cache exists
// For now, just verify the mock adapter works
let result = mock_adapter.get_exchange_rate("USD", "EUR").await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_exchange_rate_service_get_available_adapters() {
let mock_adapter: Arc<dyn ExchangeRateAdapter + Send + Sync> =
Arc::new(MockAdapter::new("test"));
// Create service - we need to bypass the DbService type issue for this test
// by testing the trait method directly
let adapters = mock_adapter.get_supported_currencies(None).await;
assert!(!adapters.is_empty());
}
#[test]
fn test_decimal_rate_parsing() {
// Test that we can parse decimal rates correctly
let rate_str = "0.8512345678";
let rate = Decimal::from_str_exact(rate_str).expect("Failed to parse rate string");
assert_eq!(rate.to_string(), rate_str);
// Test with larger rate
let large_rate_str = "150.123456789012";
let large_rate =
Decimal::from_str_exact(large_rate_str).expect("Failed to parse large rate string");
assert_eq!(large_rate.to_string(), large_rate_str);
// Test with small rate
let small_rate_str = "0.00000001";
let _small_rate =
Decimal::from_str_exact(small_rate_str).expect("Failed to parse small rate string");
}
#[test]
fn test_timestamp_conversion() {
// Test timestamp to DateTime conversion
let timestamp: i64 = 1704067200; // 2024-01-01 00:00:00 UTC
let dt = chrono::DateTime::from_timestamp(timestamp, 0);
assert!(dt.is_some());
let dt = dt.expect("DateTime conversion failed");
assert_eq!(dt.timestamp(), timestamp);
// Test conversion to naive UTC
let naive = dt.naive_utc();
assert_eq!(naive.and_utc().timestamp(), timestamp);
}
#[test]
fn test_naive_date_time_to_utc() {
let naive = NaiveDateTime::parse_from_str("2024-01-01 12:00:00", "%Y-%m-%d %H:%M:%S")
.expect("Failed to parse datetime");
let utc: chrono::DateTime<Utc> =
chrono::DateTime::from_naive_utc_and_offset(naive, chrono::Utc);
assert_eq!(utc.naive_utc(), naive);
}
#[tokio::test]
async fn test_service_trait_implementation() {
// Verify that ExchangeRateServiceImpl implements ServiceTrait
fn _check_service_trait<T: ServiceTrait>() {}
// This is a compile-time check
// If ExchangeRateServiceImpl doesn't implement ServiceTrait, this won't compile
// We can't call it directly due to DbService, but the type check works
}
#[tokio::test]
async fn test_mock_adapter_error_handling() {
let adapter = MockAdapter::new("test").with_error();
let result = adapter.get_exchange_rate("USD", "XXX").await;
assert!(result.is_err());
match result {
Err(AppError::Internal(msg)) => assert_eq!(msg, "Mock error"),
_ => unreachable!("Expected AppError::Internal"),
}
}
#[test]
fn test_adapter_name_cases() {
// Test that adapter names are consistent
let names = get_adapter_info()
.iter()
.map(|info| info.name.clone())
.collect::<Vec<String>>();
for name in &names {
let adapter = get_adapter_by_name(name);
assert!(adapter.is_some(), "Adapter '{}' should exist", name);
}
}
#[tokio::test]
async fn test_supported_currencies_cache() {
// Test that currencies can be retrieved
let adapter =
MockAdapter::new("test").with_currencies(vec!["USD".to_string(), "EUR".to_string()]);
let currencies = adapter.get_supported_currencies(Some("USD")).await;
assert_eq!(currencies.len(), 2);
// Test with None base currency
let currencies_none = adapter.get_supported_currencies(None).await;
assert_eq!(currencies_none.len(), 2);
}
#[test]
fn test_exchange_rate_precision() {
// Verify decimal precision for financial calculations
let rate1 =
Decimal::from_f64_retain(0.1234567890).expect("Failed to retain f64 as Decimal");
let rate2 =
Decimal::from_f64_retain(0.9876543210).expect("Failed to retain f64 as Decimal");
let product = rate1 * rate2;
// Decimal should maintain precision
assert!(product > Decimal::ZERO);
// Test string conversion round-trip
let rate_str = rate1.to_string();
let _rate_parsed = Decimal::from_str_exact(&rate_str).expect("Failed to parse rate string");
}
}

View File

@@ -1,10 +1,82 @@
use std::sync::Arc;
use sea_orm::DatabaseConnection;
use crate::errors::CommandResult;
pub mod accounts;
pub mod balance_calculator;
pub mod exchange_rate;
// pub mod goals;
// pub mod reconciliations;
// pub mod scheduled;
pub mod settings;
pub mod tags;
pub mod transactions;
pub mod transfers;
#[async_trait::async_trait]
pub trait ServiceTrait: Send + Sync {
async fn on_app_start(&self) -> CommandResult<()> {
Ok(())
}
}
/// Service factory for creating service instances
pub struct ServiceFactory;
pub struct ServiceFactoryResult {
pub account_service: Arc<dyn accounts::service::AccountService>,
pub transaction_service: Arc<dyn transactions::service::TransactionService>,
pub settings_service: Arc<dyn settings::service::SettingsService>,
pub exchange_rate_service: Arc<dyn exchange_rate::service::ExchangeRateService>,
pub tag_service: Arc<dyn tags::service::TagService>,
pub transfer_service: Arc<dyn transfers::service::TransferService>,
// pub scheduled_service: Arc<dyn scheduled::service::ScheduledTransactionService>,
// pub goal_service: Arc<dyn goals::service::GoalService>,
// pub reconciliation_service: Arc<dyn reconciliations::service::ReconciliationService>,
}
impl ServiceFactory {
pub fn create_services(db: DatabaseConnection) -> () {
()
pub async fn create_services(db: DatabaseConnection) -> ServiceFactoryResult {
let account_service = Arc::new(accounts::service::AccountServiceImpl::new(db.clone()));
let settings_service = Arc::new(settings::service::SettingsServiceImpl::new(db.clone()));
let exchange_rate_service = Arc::new(
exchange_rate::service::ExchangeRateServiceImpl::new(
db.clone(),
settings_service.clone(),
)
.await,
);
let tag_service: Arc<dyn tags::service::TagService> =
Arc::new(tags::service::TagServiceImpl::new(db.clone()));
let transaction_service = Arc::new(transactions::service::TransactionServiceImpl::new(
db.clone(),
tag_service.clone(),
));
let transfer_service = Arc::new(transfers::service::TransferServiceImpl::new(
db.clone(),
transaction_service.clone(),
));
// let scheduled_service = Arc::new(scheduled::service::ScheduledTransactionServiceImpl::new(
// db.clone(),
// tag_service.clone(),
// ));
// let goal_service = Arc::new(goals::service::GoalServiceImpl::new(db.clone()));
// let reconciliation_service = Arc::new(
// reconciliations::service::ReconciliationServiceImpl::new(db.clone()),
// );
ServiceFactoryResult {
account_service,
transaction_service,
exchange_rate_service,
settings_service,
tag_service,
transfer_service,
// scheduled_service,
// goal_service,
// reconciliation_service,
}
}
}

View File

@@ -0,0 +1,2 @@
pub mod service;
pub mod types;

View File

@@ -0,0 +1,635 @@
use std::collections::HashMap;
use async_trait::async_trait;
use chrono::Utc;
use log::warn;
use sea_orm::{
ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter,
};
use struct_iterable::Iterable;
use crate::{
db::{connection::ConnectionSource, entities::settings},
errors::CommandResult,
services::{
ServiceTrait,
settings::types::settings::{Settings, UpdateSettingsInput},
},
};
pub type SettingModel = settings::Model;
#[async_trait]
pub trait SettingsService: Send + Sync {
async fn get_settings(&self, tx: Option<&ConnectionSource<'_>>) -> CommandResult<Settings>;
async fn get_setting_with_prefix(&self, prefix: &str) -> HashMap<String, String>;
async fn get_setting(&self, key: &str, default: &str) -> String;
async fn update_setting(
&self,
key: String,
value: String,
tx: Option<&ConnectionSource<'_>>,
) -> CommandResult<()>;
async fn update_settings(
&self,
input: UpdateSettingsInput,
tx: Option<&ConnectionSource<'_>>,
) -> CommandResult<()>;
async fn initialize_default_settings(
&self,
tx: Option<&ConnectionSource<'_>>,
) -> CommandResult<()>;
}
#[async_trait]
impl ServiceTrait for dyn SettingsService {
async fn on_app_start(&self) -> CommandResult<()> {
self.initialize_default_settings(None).await
}
}
pub struct SettingsServiceImpl {
db: DatabaseConnection,
}
impl SettingsServiceImpl {
pub fn new(db: DatabaseConnection) -> Self {
Self { db }
}
}
#[async_trait]
impl SettingsService for SettingsServiceImpl {
async fn get_settings(&self, tx: Option<&ConnectionSource<'_>>) -> CommandResult<Settings> {
let settings_list = crate::db::entities::settings::Entity::find()
.all(&tx.unwrap_or(&ConnectionSource::Connection(&self.db)))
.await?;
let mut map = HashMap::new();
for setting in settings_list {
map.insert(setting.key, setting.value.unwrap_or_default());
}
Ok(Settings::from(map))
}
async fn get_setting_with_prefix(&self, prefix: &str) -> HashMap<String, String> {
let settings_list = crate::db::entities::settings::Entity::find()
.filter(settings::Column::Key.starts_with(prefix.to_string()))
.all(&self.db)
.await
.unwrap_or_default();
let mut map = HashMap::new();
for setting in settings_list {
map.insert(setting.key, setting.value.unwrap_or_default());
}
map
}
async fn get_setting(&self, key: &str, default: &str) -> String {
crate::db::entities::settings::Entity::find_by_id(key.to_string())
.one(&self.db)
.await
.ok()
.flatten()
.and_then(|s| s.value)
.unwrap_or_else(|| default.to_string())
}
async fn update_setting(
&self,
key: String,
value: String,
tx: Option<&ConnectionSource<'_>>,
) -> CommandResult<()> {
let now = Utc::now().naive_utc();
let tx_ = match tx {
Some(tx) => tx,
_ => &ConnectionSource::Connection(&self.db),
};
let existing = crate::db::entities::settings::Entity::find_by_id(key.clone())
.one(&tx_)
.await?;
if let Some(setting) = existing {
let mut active_model: settings::ActiveModel = setting.into();
active_model.value = Set(Some(value));
active_model.updated_at = Set(now);
active_model.update(&tx_).await?;
} else {
let new_setting = settings::ActiveModel {
key: Set(key),
value: Set(Some(value)),
updated_at: Set(now),
};
new_setting.insert(&tx_).await?;
}
Ok(())
}
async fn update_settings(
&self,
input: UpdateSettingsInput,
tx: Option<&ConnectionSource<'_>>,
) -> CommandResult<()> {
let tx_ = match tx {
Some(tx) => tx,
_ => &ConnectionSource::Connection(&self.db),
};
for (key, value) in input.settings {
self.update_setting(key, value, Some(tx_)).await?;
}
Ok(())
}
async fn initialize_default_settings(
&self,
tx: Option<&ConnectionSource<'_>>,
) -> CommandResult<()> {
let default_settings = Settings::default();
let settings_map: HashMap<String, String> = default_settings
.iter()
.filter_map(|(k, v)| -> Option<(String, String)> {
let value_string = if let Some(s) = v.downcast_ref::<String>() {
s.clone()
} else if let Some(i) = v.downcast_ref::<i32>() {
i.to_string()
} else if let Some(e) =
v.downcast_ref::<crate::services::settings::types::language::Language>()
{
e.to_string()
} else if let Some(e) =
v.downcast_ref::<crate::services::settings::types::view::View>()
{
e.to_string()
} else if let Some(e) =
v.downcast_ref::<crate::services::settings::types::display_mode::DisplayMode>()
{
e.to_string()
} else if let Some(e) =
v.downcast_ref::<crate::services::settings::types::theme::Theme>()
{
e.to_string()
} else if let Some(e) =
v.downcast_ref::<crate::services::settings::types::date_of_week::DateOfWeek>()
{
e.to_string()
} else {
warn!("Unsupported setting type for key '{k}'");
return None;
};
Some((k.to_string(), value_string))
})
.collect();
let tx_ = match tx {
Some(tx) => tx,
_ => &ConnectionSource::Connection(&self.db),
};
for (key, value) in settings_map {
let existing = crate::db::entities::settings::Entity::find_by_id(key.clone())
.one(&tx_)
.await?;
if existing.is_none() {
self.update_setting(key.clone(), value.clone(), Some(tx_))
.await?;
}
}
Ok(())
}
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
use chrono::NaiveDateTime;
use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult};
fn create_mock_setting(key: &str, value: &str) -> settings::Model {
settings::Model {
key: key.to_string(),
value: Some(value.to_string()),
updated_at: NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
.expect("Failed to parse datetime"),
}
}
#[tokio::test]
async fn test_get_settings_empty() {
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![] as Vec<settings::Model>])
.into_connection();
let service = SettingsServiceImpl::new(db);
let result = service.get_settings(None).await;
assert!(result.is_ok());
let settings = result.expect("Failed to get settings");
// Should return default settings when no settings exist
assert_eq!(settings.language.to_string(), "en");
assert_eq!(settings.default_currency, "HKD");
assert_eq!(settings.base_currency, "HKD");
}
#[tokio::test]
async fn test_get_settings_with_values() {
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![
create_mock_setting("language", "zh-TW"),
create_mock_setting("default_currency", "USD"),
create_mock_setting("base_currency", "EUR"),
create_mock_setting("timezone", "Asia/Tokyo"),
] as Vec<settings::Model>])
.into_connection();
let service = SettingsServiceImpl::new(db);
let result = service.get_settings(None).await;
assert!(result.is_ok());
let settings = result.expect("Failed to get settings");
assert_eq!(settings.language.to_string(), "zh-TW");
assert_eq!(settings.default_currency, "USD");
assert_eq!(settings.base_currency, "EUR");
assert_eq!(settings.timezone, "Asia/Tokyo");
}
#[tokio::test]
async fn test_update_setting_insert_new() {
// Mock: find_by_id returns None (setting doesn't exist)
// Insert returns the inserted model via RETURNING clause
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![] as Vec<settings::Model>]) // find_by_id
.append_query_results(vec![
vec![create_mock_setting("language", "zh-HK")] as Vec<settings::Model>
]) // insert returning
.append_exec_results(vec![MockExecResult {
last_insert_id: 1,
rows_affected: 1,
}])
.into_connection();
let service = SettingsServiceImpl::new(db);
let result = service
.update_setting("language".to_string(), "zh-HK".to_string(), None)
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_update_setting_update_existing() {
// Mock: find_by_id returns existing setting
// Update returns the updated model via RETURNING clause
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![
vec![create_mock_setting("language", "en")] as Vec<settings::Model>
]) // find_by_id
.append_query_results(vec![
vec![create_mock_setting("language", "zh-TW")] as Vec<settings::Model>
]) // update returning
.append_exec_results(vec![MockExecResult {
last_insert_id: 0,
rows_affected: 1,
}])
.into_connection();
let service = SettingsServiceImpl::new(db);
let result = service
.update_setting("language".to_string(), "zh-TW".to_string(), None)
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_update_settings_multiple() {
// Mock two update operations
let db = MockDatabase::new(DatabaseBackend::Sqlite)
// First update: language - find returns None, then insert
.append_query_results(vec![vec![] as Vec<settings::Model>])
.append_query_results(vec![
vec![create_mock_setting("language", "zh-TW")] as Vec<settings::Model>
])
.append_exec_results(vec![MockExecResult {
last_insert_id: 1,
rows_affected: 1,
}])
// Second update: theme - find returns None, then insert
.append_query_results(vec![vec![] as Vec<settings::Model>])
.append_query_results(vec![
vec![create_mock_setting("theme", "dark")] as Vec<settings::Model>
])
.append_exec_results(vec![MockExecResult {
last_insert_id: 2,
rows_affected: 1,
}])
.into_connection();
let service = SettingsServiceImpl::new(db);
let input = UpdateSettingsInput {
settings: {
let mut map = HashMap::new();
map.insert("language".to_string(), "zh-TW".to_string());
map.insert("theme".to_string(), "dark".to_string());
map
},
};
let result = service.update_settings(input, None).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_initialize_default_settings() {
// Settings has 12 fields, we mock: 1 exists + 11 inserts
// For each insert: initialize_default_settings does find_by_id, then update_setting does (find_by_id + insert)
// So each insert needs 2 find queries + 1 insert returning query + 1 exec
let db = MockDatabase::new(DatabaseBackend::Sqlite)
// Check language (exists) - only find, no insert
.append_query_results(vec![
vec![create_mock_setting("language", "en")] as Vec<settings::Model>
])
// Check default_currency (not exists) -> update_setting -> find + insert
.append_query_results(vec![vec![] as Vec<settings::Model>]) // initialize_default_settings find
.append_query_results(vec![vec![] as Vec<settings::Model>]) // update_setting find
.append_query_results(vec![
vec![create_mock_setting("default_currency", "HKD")] as Vec<settings::Model>
]) // insert returning
.append_exec_results(vec![MockExecResult {
last_insert_id: 1,
rows_affected: 1,
}])
// Check base_currency (not exists) -> insert
.append_query_results(vec![vec![] as Vec<settings::Model>])
.append_query_results(vec![vec![] as Vec<settings::Model>])
.append_query_results(vec![
vec![create_mock_setting("base_currency", "HKD")] as Vec<settings::Model>
])
.append_exec_results(vec![MockExecResult {
last_insert_id: 2,
rows_affected: 1,
}])
// Check timezone (not exists) -> insert
.append_query_results(vec![vec![] as Vec<settings::Model>])
.append_query_results(vec![vec![] as Vec<settings::Model>])
.append_query_results(vec![
vec![create_mock_setting("timezone", "auto")] as Vec<settings::Model>
])
.append_exec_results(vec![MockExecResult {
last_insert_id: 3,
rows_affected: 1,
}])
// Check default_view (not exists) -> insert
.append_query_results(vec![vec![] as Vec<settings::Model>])
.append_query_results(vec![vec![] as Vec<settings::Model>])
.append_query_results(vec![
vec![create_mock_setting("default_view", "combined")] as Vec<settings::Model>
])
.append_exec_results(vec![MockExecResult {
last_insert_id: 4,
rows_affected: 1,
}])
// Check display_mode (not exists) -> insert
.append_query_results(vec![vec![] as Vec<settings::Model>])
.append_query_results(vec![vec![] as Vec<settings::Model>])
.append_query_results(vec![
vec![create_mock_setting("display_mode", "system")] as Vec<settings::Model>
])
.append_exec_results(vec![MockExecResult {
last_insert_id: 5,
rows_affected: 1,
}])
// Check decimal_places (not exists) -> insert
.append_query_results(vec![vec![] as Vec<settings::Model>])
.append_query_results(vec![vec![] as Vec<settings::Model>])
.append_query_results(vec![
vec![create_mock_setting("decimal_places", "2")] as Vec<settings::Model>
])
.append_exec_results(vec![MockExecResult {
last_insert_id: 6,
rows_affected: 1,
}])
// Check date_format (not exists) -> insert
.append_query_results(vec![vec![] as Vec<settings::Model>])
.append_query_results(vec![vec![] as Vec<settings::Model>])
.append_query_results(vec![
vec![create_mock_setting("date_format", "YYYY-MM-DD")] as Vec<settings::Model>
])
.append_exec_results(vec![MockExecResult {
last_insert_id: 7,
rows_affected: 1,
}])
// Check time_format (not exists) -> insert
.append_query_results(vec![vec![] as Vec<settings::Model>])
.append_query_results(vec![vec![] as Vec<settings::Model>])
.append_query_results(vec![
vec![create_mock_setting("time_format", "24h")] as Vec<settings::Model>
])
.append_exec_results(vec![MockExecResult {
last_insert_id: 8,
rows_affected: 1,
}])
// Check theme (not exists) -> insert
.append_query_results(vec![vec![] as Vec<settings::Model>])
.append_query_results(vec![vec![] as Vec<settings::Model>])
.append_query_results(vec![
vec![create_mock_setting("theme", "system")] as Vec<settings::Model>
])
.append_exec_results(vec![MockExecResult {
last_insert_id: 9,
rows_affected: 1,
}])
// Check week_starts_on (not exists) -> insert
.append_query_results(vec![vec![] as Vec<settings::Model>])
.append_query_results(vec![vec![] as Vec<settings::Model>])
.append_query_results(vec![
vec![create_mock_setting("week_starts_on", "sunday")] as Vec<settings::Model>
])
.append_exec_results(vec![MockExecResult {
last_insert_id: 10,
rows_affected: 1,
}])
// Check scheduled_check_interval (not exists) -> insert
.append_query_results(vec![vec![] as Vec<settings::Model>])
.append_query_results(vec![vec![] as Vec<settings::Model>])
.append_query_results(vec![
vec![create_mock_setting("scheduled_check_interval", "1")] as Vec<settings::Model>,
])
.append_exec_results(vec![MockExecResult {
last_insert_id: 11,
rows_affected: 1,
}])
.into_connection();
let service = SettingsServiceImpl::new(db);
let result = service.initialize_default_settings(None).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_initialize_default_settings_all_exist() {
// Mock scenario where all default settings already exist
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![
vec![create_mock_setting("language", "en")] as Vec<settings::Model>
])
.append_query_results(vec![
vec![create_mock_setting("default_currency", "HKD")] as Vec<settings::Model>
])
.append_query_results(vec![
vec![create_mock_setting("base_currency", "HKD")] as Vec<settings::Model>
])
.append_query_results(vec![
vec![create_mock_setting("timezone", "auto")] as Vec<settings::Model>
])
.append_query_results(vec![
vec![create_mock_setting("default_view", "combined")] as Vec<settings::Model>
])
.append_query_results(vec![
vec![create_mock_setting("display_mode", "system")] as Vec<settings::Model>
])
.append_query_results(vec![
vec![create_mock_setting("decimal_places", "2")] as Vec<settings::Model>
])
.append_query_results(vec![
vec![create_mock_setting("date_format", "YYYY-MM-DD")] as Vec<settings::Model>
])
.append_query_results(vec![
vec![create_mock_setting("time_format", "24h")] as Vec<settings::Model>
])
.append_query_results(vec![
vec![create_mock_setting("theme", "system")] as Vec<settings::Model>
])
.append_query_results(vec![
vec![create_mock_setting("week_starts_on", "sunday")] as Vec<settings::Model>
])
.append_query_results(vec![
vec![create_mock_setting("scheduled_check_interval", "1")] as Vec<settings::Model>,
])
.into_connection();
let service = SettingsServiceImpl::new(db);
let result = service.initialize_default_settings(None).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_get_setting_with_default() {
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![] as Vec<settings::Model>])
.into_connection();
let service = SettingsServiceImpl::new(db);
let result = service
.get_setting("nonexistent_key", "default_value")
.await;
assert_eq!(result, "default_value");
}
#[tokio::test]
async fn test_get_setting_with_value() {
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![
vec![create_mock_setting("existing_key", "stored_value")] as Vec<settings::Model>,
])
.into_connection();
let service = SettingsServiceImpl::new(db);
let result = service.get_setting("existing_key", "default_value").await;
assert_eq!(result, "stored_value");
}
#[tokio::test]
async fn test_settings_with_all_fields() {
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![
create_mock_setting("language", "zh-HK"),
create_mock_setting("default_currency", "USD"),
create_mock_setting("base_currency", "EUR"),
create_mock_setting("timezone", "UTC"),
create_mock_setting("default_view", "split"),
create_mock_setting("display_mode", "dark"),
create_mock_setting("decimal_places", "4"),
create_mock_setting("date_format", "DD/MM/YYYY"),
create_mock_setting("time_format", "12h"),
create_mock_setting("theme", "light"),
create_mock_setting("week_starts_on", "monday"),
create_mock_setting("scheduled_check_interval", "7"),
] as Vec<settings::Model>])
.into_connection();
let service = SettingsServiceImpl::new(db);
let result = service.get_settings(None).await;
assert!(result.is_ok());
let settings = result.expect("Failed to get settings");
assert_eq!(settings.language.to_string(), "zh-HK");
assert_eq!(settings.default_currency, "USD");
assert_eq!(settings.base_currency, "EUR");
assert_eq!(settings.timezone, "UTC");
assert_eq!(settings.default_view.to_string(), "split");
assert_eq!(settings.display_mode.to_string(), "dark");
assert_eq!(settings.decimal_places, 4);
assert_eq!(settings.date_format, "DD/MM/YYYY");
assert_eq!(settings.time_format, "12h");
assert_eq!(settings.theme.to_string(), "light");
assert_eq!(settings.week_starts_on.to_string(), "monday");
assert_eq!(settings.scheduled_check_interval, 7);
}
#[tokio::test]
async fn test_update_setting_with_null_value_in_db() {
let setting_with_null = settings::Model {
key: "test_key".to_string(),
value: None,
updated_at: NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
.expect("Failed to parse datetime"),
};
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![setting_with_null] as Vec<settings::Model>]) // find_by_id
.append_query_results(vec![
vec![create_mock_setting("test_key", "new_value")] as Vec<settings::Model>
]) // update returning
.append_exec_results(vec![MockExecResult {
last_insert_id: 0,
rows_affected: 1,
}])
.into_connection();
let service = SettingsServiceImpl::new(db);
let result = service
.update_setting("test_key".to_string(), "new_value".to_string(), None)
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_settings_transaction_logs() {
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![] as Vec<settings::Model>])
.append_exec_results(vec![MockExecResult {
last_insert_id: 0,
rows_affected: 1,
}])
.into_connection();
let service = SettingsServiceImpl::new(db);
// Verify that the database backend is SQLite
assert_eq!(service.db.get_database_backend(), DatabaseBackend::Sqlite);
}
}

View File

@@ -0,0 +1,83 @@
use serde::Serialize;
const SUNDAY: &str = "sunday";
const MONDAY: &str = "monday";
const TUESDAY: &str = "tuesday";
const WEDNESDAY: &str = "wednesday";
const THURSDAY: &str = "thursday";
const FRIDAY: &str = "friday";
const SATURDAY: &str = "saturday";
#[derive(Debug, Serialize, Default)]
pub enum DateOfWeek {
#[default]
Sunday,
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday,
}
impl std::fmt::Display for DateOfWeek {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DateOfWeek::Sunday => write!(f, "{}", SUNDAY),
DateOfWeek::Monday => write!(f, "{}", MONDAY),
DateOfWeek::Tuesday => write!(f, "{}", TUESDAY),
DateOfWeek::Wednesday => write!(f, "{}", WEDNESDAY),
DateOfWeek::Thursday => write!(f, "{}", THURSDAY),
DateOfWeek::Friday => write!(f, "{}", FRIDAY),
DateOfWeek::Saturday => write!(f, "{}", SATURDAY),
}
}
}
impl From<Option<String>> for DateOfWeek {
fn from(s: Option<String>) -> Self {
match s {
Some(s) => match s.as_str() {
SUNDAY => DateOfWeek::Sunday,
MONDAY => DateOfWeek::Monday,
TUESDAY => DateOfWeek::Tuesday,
WEDNESDAY => DateOfWeek::Wednesday,
THURSDAY => DateOfWeek::Thursday,
FRIDAY => DateOfWeek::Friday,
SATURDAY => DateOfWeek::Saturday,
_ => DateOfWeek::default(),
},
None => DateOfWeek::default(),
}
}
}
impl From<String> for DateOfWeek {
fn from(s: String) -> Self {
DateOfWeek::from(Some(s))
}
}
impl From<Option<i32>> for DateOfWeek {
fn from(i: Option<i32>) -> Self {
match i {
Some(i) => match i {
0 => DateOfWeek::Sunday,
1 => DateOfWeek::Monday,
2 => DateOfWeek::Tuesday,
3 => DateOfWeek::Wednesday,
4 => DateOfWeek::Thursday,
5 => DateOfWeek::Friday,
6 => DateOfWeek::Saturday,
_ => DateOfWeek::default(),
},
None => DateOfWeek::default(),
}
}
}
impl From<i32> for DateOfWeek {
fn from(i: i32) -> Self {
DateOfWeek::from(Some(i))
}
}

View File

@@ -0,0 +1,43 @@
use serde::Serialize;
const SYSTEM: &str = "system";
const LIGHT: &str = "light";
const DARK: &str = "dark";
#[derive(Debug, Serialize, Default)]
pub enum DisplayMode {
#[default]
System,
Light,
Dark,
}
impl std::fmt::Display for DisplayMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DisplayMode::System => write!(f, "{}", SYSTEM),
DisplayMode::Light => write!(f, "{}", LIGHT),
DisplayMode::Dark => write!(f, "{}", DARK),
}
}
}
impl From<Option<String>> for DisplayMode {
fn from(s: Option<String>) -> Self {
match s {
Some(s) => match s.as_str() {
SYSTEM => DisplayMode::System,
LIGHT => DisplayMode::Light,
DARK => DisplayMode::Dark,
_ => DisplayMode::default(),
},
None => DisplayMode::default(),
}
}
}
impl From<String> for DisplayMode {
fn from(s: String) -> Self {
DisplayMode::from(Some(s))
}
}

View File

@@ -0,0 +1,43 @@
use serde::Serialize;
const ENGLISH: &str = "en";
const TRADITIONAL_CHINESE: &str = "zh-TW";
const CANTONESE: &str = "zh-HK";
#[derive(Debug, Serialize, Default)]
pub enum Language {
#[default]
English,
TraditionalChinese,
Cantonese,
}
impl std::fmt::Display for Language {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Language::English => write!(f, "{}", ENGLISH),
Language::TraditionalChinese => write!(f, "{}", TRADITIONAL_CHINESE),
Language::Cantonese => write!(f, "{}", CANTONESE),
}
}
}
impl From<Option<String>> for Language {
fn from(s: Option<String>) -> Self {
match s {
Some(s) => match s.as_str() {
ENGLISH => Language::English,
TRADITIONAL_CHINESE => Language::TraditionalChinese,
CANTONESE => Language::Cantonese,
_ => Language::default(),
},
None => Language::default(),
}
}
}
impl From<String> for Language {
fn from(s: String) -> Self {
Language::from(Some(s))
}
}

View File

@@ -0,0 +1,6 @@
pub mod date_of_week;
pub mod display_mode;
pub mod language;
pub mod settings;
pub mod theme;
pub mod view;

View File

@@ -0,0 +1,107 @@
use serde::{Deserialize, Serialize};
use struct_iterable::Iterable;
use crate::services::settings::types::date_of_week::DateOfWeek;
use super::{display_mode::DisplayMode, language::Language, theme::Theme, view::View};
const LANGUAGE_KEY: &str = "language";
const DEFAULT_CURRENCY_KEY: &str = "default_currency";
const BASE_CURRENCY_KEY: &str = "base_currency";
const TIMEZONE_KEY: &str = "timezone";
const DEFAULT_VIEW_KEY: &str = "default_view";
const DISPLAY_MODE_KEY: &str = "display_mode";
const DECIMAL_PLACES_KEY: &str = "decimal_places";
const DATE_FORMAT_KEY: &str = "date_format";
const TIME_FORMAT_KEY: &str = "time_format";
const THEME_KEY: &str = "theme";
const WEEK_STARTS_ON_KEY: &str = "week_starts_on";
const SCHEDULED_CHECK_INTERVAL_KEY: &str = "scheduled_check_interval";
const DEFAULT_DECIMAL_PLACES: i32 = 2;
const DEFAULT_SCHEDULED_CHECK_INTERVAL: i32 = 1;
#[derive(Debug, Serialize, Iterable)]
pub struct Settings {
pub language: Language,
pub default_currency: String,
pub base_currency: String,
pub timezone: String,
pub default_view: View,
pub display_mode: DisplayMode,
pub decimal_places: i32,
pub date_format: String,
pub time_format: String,
pub theme: Theme,
pub week_starts_on: DateOfWeek,
pub scheduled_check_interval: i32,
}
#[derive(Debug, Deserialize)]
pub struct UpdateSettingsInput {
pub settings: std::collections::HashMap<String, String>,
}
impl Default for Settings {
fn default() -> Self {
Settings {
language: Language::default(),
default_currency: "HKD".to_string(),
base_currency: "HKD".to_string(),
timezone: "auto".to_string(),
default_view: View::default(),
display_mode: DisplayMode::default(),
decimal_places: DEFAULT_DECIMAL_PLACES,
date_format: "YYYY-MM-DD".to_string(),
time_format: "24h".to_string(),
theme: Theme::default(),
week_starts_on: DateOfWeek::default(),
scheduled_check_interval: DEFAULT_SCHEDULED_CHECK_INTERVAL,
}
}
}
impl From<std::collections::HashMap<String, String>> for Settings {
fn from(map: std::collections::HashMap<String, String>) -> Self {
let default_settings = Settings::default();
Settings {
language: map.get(LANGUAGE_KEY).cloned().into(),
default_currency: map
.get(DEFAULT_CURRENCY_KEY)
.cloned()
.unwrap_or(default_settings.default_currency),
base_currency: map
.get(BASE_CURRENCY_KEY)
.cloned()
.unwrap_or(default_settings.base_currency),
timezone: map
.get(TIMEZONE_KEY)
.cloned()
.unwrap_or(default_settings.timezone),
default_view: map.get(DEFAULT_VIEW_KEY).cloned().into(),
display_mode: map.get(DISPLAY_MODE_KEY).cloned().into(),
decimal_places: map
.get(DECIMAL_PLACES_KEY)
.cloned()
.unwrap_or(default_settings.decimal_places.to_string())
.parse()
.unwrap_or(DEFAULT_DECIMAL_PLACES),
date_format: map
.get(DATE_FORMAT_KEY)
.cloned()
.unwrap_or(default_settings.date_format),
time_format: map
.get(TIME_FORMAT_KEY)
.cloned()
.unwrap_or(default_settings.time_format),
theme: map.get(THEME_KEY).cloned().into(),
week_starts_on: map.get(WEEK_STARTS_ON_KEY).cloned().into(),
scheduled_check_interval: map
.get(SCHEDULED_CHECK_INTERVAL_KEY)
.cloned()
.unwrap_or(default_settings.scheduled_check_interval.to_string())
.parse()
.unwrap_or(DEFAULT_SCHEDULED_CHECK_INTERVAL),
}
}
}

View File

@@ -0,0 +1,43 @@
use serde::Serialize;
const SYSTEM: &str = "system";
const LIGHT: &str = "light";
const DARK: &str = "dark";
#[derive(Debug, Serialize, Default)]
pub enum Theme {
#[default]
System,
Light,
Dark,
}
impl std::fmt::Display for Theme {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Theme::System => write!(f, "{}", SYSTEM),
Theme::Light => write!(f, "{}", LIGHT),
Theme::Dark => write!(f, "{}", DARK),
}
}
}
impl From<Option<String>> for Theme {
fn from(s: Option<String>) -> Self {
match s {
Some(s) => match s.as_str() {
SYSTEM => Theme::System,
LIGHT => Theme::Light,
DARK => Theme::Dark,
_ => Theme::default(),
},
None => Theme::default(),
}
}
}
impl From<String> for Theme {
fn from(s: String) -> Self {
Theme::from(Some(s))
}
}

View File

@@ -0,0 +1,39 @@
use serde::Serialize;
const COMBINED: &str = "combined";
const SPLIT: &str = "split";
#[derive(Debug, Default, Serialize)]
pub enum View {
#[default]
Combined,
Split,
}
impl std::fmt::Display for View {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
View::Combined => write!(f, "{}", COMBINED),
View::Split => write!(f, "{}", SPLIT),
}
}
}
impl From<Option<String>> for View {
fn from(s: Option<String>) -> Self {
match s {
Some(s) => match s.as_str() {
COMBINED => View::Combined,
SPLIT => View::Split,
_ => View::default(),
},
None => View::default(),
}
}
}
impl From<String> for View {
fn from(s: String) -> Self {
View::from(Some(s))
}
}

View File

@@ -0,0 +1,2 @@
pub mod service;
pub mod types;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,107 @@
use serde::Deserialize;
const VALID_BUDGET_PERIODS: &[&str] = &["daily", "weekly", "monthly", "yearly"];
#[derive(Debug, Deserialize)]
pub struct CreateTagInput {
pub name: String,
pub color: String,
pub icon: Option<String>,
pub budget_amount: Option<String>,
pub budget_period: Option<String>,
}
impl CreateTagInput {
/// Validate the input fields
pub fn validate(&self) -> Result<(), String> {
// Validate name is not empty
if self.name.trim().is_empty() {
return Err("Tag name cannot be empty".to_string());
}
// Validate name length
if self.name.len() > 100 {
return Err("Tag name cannot exceed 100 characters".to_string());
}
// Validate color is a valid hex color
if !self.color.starts_with('#') || self.color.len() != 7 {
return Err("Color must be a valid hex code (e.g., #FF5733)".to_string());
}
if !self.color[1..].chars().all(|c| c.is_ascii_hexdigit()) {
return Err("Color must be a valid hex code".to_string());
}
// Validate budget_period if provided
if let Some(ref period) = self.budget_period {
if !VALID_BUDGET_PERIODS.contains(&period.as_str()) {
return Err(format!(
"Invalid budget period: {}. Must be one of: daily, weekly, monthly, yearly",
period
));
}
}
// Validate budget_amount if provided
if let Some(ref amount) = self.budget_amount {
if amount.parse::<f64>().is_err() {
return Err(format!("Invalid budget amount: {}", amount));
}
}
Ok(())
}
}
#[derive(Debug, Deserialize)]
pub struct UpdateTagInput {
pub name: Option<String>,
pub color: Option<String>,
pub icon: Option<String>,
pub budget_amount: Option<String>,
pub budget_period: Option<String>,
}
impl UpdateTagInput {
/// Validate the input fields
pub fn validate(&self) -> Result<(), String> {
// Validate name if provided
if let Some(ref name) = self.name {
if name.trim().is_empty() {
return Err("Tag name cannot be empty".to_string());
}
if name.len() > 100 {
return Err("Tag name cannot exceed 100 characters".to_string());
}
}
// Validate color if provided
if let Some(ref color) = self.color {
if !color.starts_with('#') || color.len() != 7 {
return Err("Color must be a valid hex code (e.g., #FF5733)".to_string());
}
if !color[1..].chars().all(|c| c.is_ascii_hexdigit()) {
return Err("Color must be a valid hex code".to_string());
}
}
// Validate budget_period if provided
if let Some(ref period) = self.budget_period {
if !VALID_BUDGET_PERIODS.contains(&period.as_str()) {
return Err(format!(
"Invalid budget period: {}. Must be one of: daily, weekly, monthly, yearly",
period
));
}
}
// Validate budget_amount if provided
if let Some(ref amount) = self.budget_amount {
if amount.parse::<f64>().is_err() {
return Err(format!("Invalid budget amount: {}", amount));
}
}
Ok(())
}
}

View File

@@ -0,0 +1 @@
pub mod inputs;

View File

@@ -0,0 +1,2 @@
pub mod service;
pub mod types;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,80 @@
use serde::Deserialize;
use crate::services::balance_calculator::service::TransactionType;
#[derive(Debug, Deserialize)]
pub struct CreateTransactionInput {
pub account_id: String,
pub transaction_type: TransactionType,
pub gross_amount: String,
pub tax_amount: Option<String>,
pub net_amount: String,
pub currency: String,
pub description: String,
pub merchant: Option<String>,
pub notes: Option<String>,
pub receipt_paths: Option<Vec<String>>,
pub transaction_date: String,
pub tag_ids: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct BulkCreateTransactionInput {
pub transactions: Vec<CreateTransactionInput>,
}
#[derive(Debug, Deserialize, Default)]
pub struct UpdateTransactionInput {
pub gross_amount: Option<String>,
pub tax_amount: Option<String>,
pub net_amount: Option<String>,
pub description: Option<String>,
pub merchant: Option<String>,
pub notes: Option<String>,
pub transaction_date: Option<String>,
pub receipt_paths: Option<Vec<String>>,
pub tag_ids: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, Default, Clone)]
pub struct TransactionFilter {
pub account_id: Option<String>,
pub transaction_type: Option<TransactionType>,
pub start_date: Option<String>,
pub end_date: Option<String>,
pub needs_review: Option<bool>,
pub is_auto_inserted: Option<bool>,
pub search_query: Option<String>,
pub sort_by: Option<String>,
pub sort_order: Option<String>,
pub limit: Option<u64>,
pub offset: Option<u64>,
/// Filter by tag IDs (optional)
pub tag_ids: Option<Vec<String>>,
/// If true, transaction must have ALL specified tags. If false, ANY tag matches.
pub match_all_tags: Option<bool>,
}
#[derive(Debug, Deserialize)]
pub struct BulkDeleteInput {
pub ids: Vec<String>,
}
#[derive(Debug)]
pub struct BulkDeleteResult {
pub deleted_count: usize,
pub failed_ids: Vec<String>,
}
#[derive(Debug, serde::Serialize)]
pub struct TransactionStatistics {
pub total_income: String,
pub total_expense: String,
pub total_transfer_in: String,
pub total_transfer_out: String,
pub net_flow: String,
pub count_income: i64,
pub count_expense: i64,
pub count_transfer: i64,
pub total_count: i64,
}

View File

@@ -0,0 +1,2 @@
pub mod inputs;
pub mod outputs;

View File

@@ -0,0 +1,15 @@
use serde::Serialize;
use crate::db::entities::transactions;
#[derive(Debug, Serialize)]
pub struct TransactionWithTags {
pub transaction: transactions::Model,
pub tags: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct BulkDeleteResult {
pub deleted_count: usize,
pub failed_ids: Vec<String>,
}

View File

@@ -0,0 +1,2 @@
pub mod service;
pub mod types;

View File

@@ -0,0 +1,804 @@
use std::sync::Arc;
use async_trait::async_trait;
use chrono::Utc;
use sea_orm::{
DatabaseConnection, DatabaseTransaction, Set, TransactionTrait, entity::*, query::*,
};
use uuid::Uuid;
use crate::db::connection::ConnectionSource;
use crate::db::entities::{prelude::*, transactions, transfers};
use crate::errors::CommandResult;
use crate::services::ServiceTrait;
use crate::services::balance_calculator::service::TransactionType;
use crate::services::transactions::service::TransactionService;
use crate::services::transactions::types::inputs::{
CreateTransactionInput, UpdateTransactionInput,
};
use crate::services::transfers::types::inputs::CreateTransferInput;
use crate::services::transfers::types::outputs::TransferWithTransactions;
pub type TransferModel = transfers::Model;
pub type TransactionModel = transactions::Model;
enum TransactionRef<'a> {
Existing(&'a DatabaseTransaction),
New(DatabaseTransaction),
}
impl<'a> TransactionRef<'a> {
fn as_ref(&self) -> &DatabaseTransaction {
match self {
TransactionRef::Existing(t) => t,
TransactionRef::New(t) => t,
}
}
async fn commit(self) -> CommandResult<()> {
match self {
TransactionRef::New(t) => {
t.commit().await?;
Ok(())
}
TransactionRef::Existing(_) => Ok(()),
}
}
}
#[async_trait]
pub trait TransferService: ServiceTrait + Send + Sync {
async fn create_transfer(
&self,
input: CreateTransferInput,
tx: Option<&ConnectionSource<'_>>,
) -> CommandResult<TransferWithTransactions>;
async fn get_transfers(
&self,
account_id: Option<String>,
start_date: Option<String>,
end_date: Option<String>,
tx: Option<&ConnectionSource<'_>>,
) -> CommandResult<Vec<TransferWithTransactions>>;
async fn delete_transfer(
&self,
id: String,
tx: Option<&ConnectionSource<'_>>,
) -> CommandResult<()>;
}
pub struct TransferServiceImpl {
db: DatabaseConnection,
transaction_service: Arc<dyn TransactionService>,
}
impl ServiceTrait for TransferServiceImpl {}
impl TransferServiceImpl {
pub fn new(db: DatabaseConnection, transaction_service: Arc<dyn TransactionService>) -> Self {
Self {
db,
transaction_service,
}
}
async fn get_transaction_to_use<'a>(
&'a self,
tx: Option<&'a ConnectionSource<'a>>,
) -> CommandResult<TransactionRef<'a>> {
match tx {
Some(ConnectionSource::Transaction(txn)) => Ok(TransactionRef::Existing(txn)),
_ => {
let txn = self.db.begin().await?;
Ok(TransactionRef::New(txn))
}
}
}
}
#[async_trait]
impl TransferService for TransferServiceImpl {
async fn create_transfer(
&self,
input: CreateTransferInput,
tx: Option<&ConnectionSource<'_>>,
) -> CommandResult<TransferWithTransactions> {
let txn_ref = self.get_transaction_to_use(tx).await?;
let txn = txn_ref.as_ref();
let conn_source = ConnectionSource::Transaction(txn);
let now = Utc::now().naive_utc();
let transfer_id = Uuid::new_v4().to_string();
// Get account currencies first
let from_account = Accounts::find_by_id(&input.from_account_id)
.one(txn)
.await?
.ok_or_else(|| {
crate::errors::AppError::NotFound(format!("From account {}", input.from_account_id))
})?;
let to_account = Accounts::find_by_id(&input.to_account_id)
.one(txn)
.await?
.ok_or_else(|| {
crate::errors::AppError::NotFound(format!("To account {}", input.to_account_id))
})?;
// Create transfer out transaction using transaction service
let from_txn_input = CreateTransactionInput {
account_id: input.from_account_id.clone(),
transaction_type: TransactionType::TransferOut,
gross_amount: input.from_amount.clone(),
tax_amount: Some("0.00000000".to_string()),
net_amount: input.from_amount.clone(),
currency: from_account.currency.clone(),
description: input
.description
.clone()
.unwrap_or_else(|| "Transfer".to_string()),
merchant: None,
notes: None,
receipt_paths: None,
transaction_date: input.transfer_date.clone(),
tag_ids: Vec::new(),
};
let from_txn_result = self
.transaction_service
.create_transaction(from_txn_input, Some(&conn_source))
.await?;
// Create transfer in transaction using transaction service
let to_txn_input = CreateTransactionInput {
account_id: input.to_account_id.clone(),
transaction_type: TransactionType::TransferIn,
gross_amount: input.to_amount.clone(),
tax_amount: Some("0.00000000".to_string()),
net_amount: input.to_amount.clone(),
currency: to_account.currency.clone(),
description: input
.description
.clone()
.unwrap_or_else(|| "Transfer".to_string()),
merchant: None,
notes: None,
receipt_paths: None,
transaction_date: input.transfer_date.clone(),
tag_ids: Vec::new(),
};
let to_txn_result = self
.transaction_service
.create_transaction(to_txn_input, Some(&conn_source))
.await?;
// Update transactions with transfer linkage using transaction service
// First update the from transaction with related_transaction_id
let from_txn_update = UpdateTransactionInput {
description: None,
merchant: None,
notes: None,
gross_amount: None,
tax_amount: None,
net_amount: None,
transaction_date: None,
receipt_paths: None,
tag_ids: None,
};
self.transaction_service
.update_transaction(
from_txn_result.transaction.id.clone(),
from_txn_update,
Some(&conn_source),
)
.await?;
// Create the transfer record directly (transfer entity management stays in transfer service)
let transfer_record = transfers::ActiveModel {
id: Set(transfer_id.clone()),
from_account_id: Set(input.from_account_id.clone()),
to_account_id: Set(input.to_account_id.clone()),
from_transaction_id: Set(Some(from_txn_result.transaction.id.clone())),
to_transaction_id: Set(Some(to_txn_result.transaction.id.clone())),
from_amount: Set(input.from_amount),
to_amount: Set(input.to_amount),
exchange_rate: Set(input.exchange_rate),
exchange_rate_source: Set(input.exchange_rate_source),
fees: Set(input.fees.unwrap_or("0.00000000".to_string())),
description: Set(input.description),
transfer_date: Set(input.transfer_date),
created_at: Set(now),
version: Set(1),
device_id: Set(None),
is_deleted: Set(false),
};
let transfer_result = transfer_record.insert(txn).await?;
// Only commit if we created the transaction
txn_ref.commit().await?;
Ok(TransferWithTransactions {
transfer: transfer_result,
from_transaction: Some(from_txn_result.transaction),
to_transaction: Some(to_txn_result.transaction),
})
}
async fn get_transfers(
&self,
account_id: Option<String>,
start_date: Option<String>,
end_date: Option<String>,
tx: Option<&ConnectionSource<'_>>,
) -> CommandResult<Vec<TransferWithTransactions>> {
// Build the query
let mut query = Transfers::find().filter(transfers::Column::IsDeleted.eq(false));
if let Some(account_id) = account_id {
query = query.filter(
transfers::Column::FromAccountId
.eq(account_id.clone())
.or(transfers::Column::ToAccountId.eq(account_id)),
);
}
if let Some(start_date) = start_date {
query = query.filter(transfers::Column::TransferDate.gte(start_date));
}
if let Some(end_date) = end_date {
query = query.filter(transfers::Column::TransferDate.lte(end_date));
}
query = query.order_by_desc(transfers::Column::TransferDate);
// Execute the query using the provided transaction or the database connection
let transfers = query
.all(&tx.unwrap_or(&ConnectionSource::Connection(&self.db)))
.await?;
let mut result = Vec::new();
for t in transfers {
// Use transaction service to get transaction details
let from_txn = if let Some(ref txn_id) = t.from_transaction_id {
self.transaction_service
.get_transaction(txn_id.clone(), tx)
.await
.ok()
.map(|twt| twt.transaction)
} else {
None
};
let to_txn = if let Some(ref txn_id) = t.to_transaction_id {
self.transaction_service
.get_transaction(txn_id.clone(), tx)
.await
.ok()
.map(|twt| twt.transaction)
} else {
None
};
result.push(TransferWithTransactions {
transfer: t,
from_transaction: from_txn,
to_transaction: to_txn,
});
}
Ok(result)
}
async fn delete_transfer(
&self,
id: String,
tx: Option<&ConnectionSource<'_>>,
) -> CommandResult<()> {
let txn_ref = self.get_transaction_to_use(tx).await?;
let txn = txn_ref.as_ref();
let transfer = Transfers::find_by_id(id.clone())
.one(txn)
.await?
.ok_or_else(|| crate::errors::AppError::NotFound(format!("Transfer with id {}", id)))?;
// Soft delete associated transactions using transaction service
if let Some(from_txn_id) = &transfer.from_transaction_id {
self.transaction_service
.delete_transaction(from_txn_id.clone(), tx)
.await?;
}
if let Some(to_txn_id) = &transfer.to_transaction_id {
self.transaction_service
.delete_transaction(to_txn_id.clone(), tx)
.await?;
}
// Soft delete transfer
let mut active_transfer: transfers::ActiveModel = transfer.into();
active_transfer.is_deleted = Set(true);
active_transfer.update(txn).await?;
// Only commit if we created the transaction
txn_ref.commit().await?;
Ok(())
}
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
use crate::db::entities::accounts;
use crate::services::transactions::types::outputs::TransactionWithTags;
use chrono::NaiveDateTime;
use sea_orm::{DatabaseBackend, MockDatabase};
use std::sync::Arc;
fn create_mock_account(id: &str, name: &str, currency: &str, balance: &str) -> accounts::Model {
accounts::Model {
id: id.to_string(),
name: name.to_string(),
account_type: "checking".to_string(),
currency: currency.to_string(),
initial_balance: balance.to_string(),
current_balance: balance.to_string(),
color: Some("#3B82F6".to_string()),
icon: Some("wallet".to_string()),
sort_order: 0,
is_active: true,
is_archived: false,
include_in_net_worth: true,
show_in_combined_view: true,
created_at: NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
.expect("Failed to parse datetime"),
updated_at: NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
.expect("Failed to parse datetime"),
version: 1,
device_id: None,
is_deleted: false,
}
}
fn create_mock_transaction(
id: &str,
account_id: &str,
txn_type: &str,
amount: &str,
currency: &str,
) -> transactions::Model {
transactions::Model {
id: id.to_string(),
account_id: account_id.to_string(),
transaction_type: txn_type.to_string(),
gross_amount: amount.to_string(),
tax_amount: "0.00000000".to_string(),
net_amount: amount.to_string(),
tax_rate: None,
currency: currency.to_string(),
description: "Test transfer".to_string(),
merchant: None,
notes: None,
receipt_paths: None,
receipt_ocr_data: None,
transfer_id: None,
related_transaction_id: None,
schedule_id: None,
is_scheduled_instance: false,
is_auto_inserted: false,
needs_review: false,
transaction_date: "2024-01-15".to_string(),
created_at: NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
.expect("Failed to parse datetime"),
updated_at: NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
.expect("Failed to parse datetime"),
version: 1,
device_id: None,
is_deleted: false,
sync_status: "synced".to_string(),
}
}
fn create_mock_transfer(
id: &str,
from_account_id: &str,
to_account_id: &str,
from_amount: &str,
to_amount: &str,
) -> transfers::Model {
transfers::Model {
id: id.to_string(),
from_account_id: from_account_id.to_string(),
to_account_id: to_account_id.to_string(),
from_transaction_id: Some(format!("txn-from-{}", id)),
to_transaction_id: Some(format!("txn-to-{}", id)),
from_amount: from_amount.to_string(),
to_amount: to_amount.to_string(),
exchange_rate: None,
exchange_rate_source: None,
fees: "0.00000000".to_string(),
description: Some("Test transfer".to_string()),
transfer_date: "2024-01-15".to_string(),
created_at: NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
.expect("Failed to parse datetime"),
version: 1,
device_id: None,
is_deleted: false,
}
}
fn create_test_transfer_input() -> CreateTransferInput {
CreateTransferInput {
from_account_id: "acc-from-1".to_string(),
to_account_id: "acc-to-1".to_string(),
from_amount: "100.00000000".to_string(),
to_amount: "100.00000000".to_string(),
exchange_rate: None,
exchange_rate_source: None,
fees: None,
description: Some("Test transfer".to_string()),
transfer_date: "2024-01-15".to_string(),
}
}
/// Mock implementation of TransactionService for testing
struct MockTransactionService {
db: DatabaseConnection,
}
impl ServiceTrait for MockTransactionService {}
#[async_trait]
impl TransactionService for MockTransactionService {
async fn create_transaction(
&self,
input: CreateTransactionInput,
_tx: Option<&ConnectionSource<'_>>,
) -> CommandResult<TransactionWithTags> {
let txn = create_mock_transaction(
&Uuid::new_v4().to_string(),
&input.account_id,
&input.transaction_type.to_string(),
&input.net_amount,
&input.currency,
);
Ok(TransactionWithTags {
transaction: txn,
tags: input.tag_ids,
})
}
async fn bulk_create_transactions(
&self,
_inputs: Vec<CreateTransactionInput>,
_tx: Option<&ConnectionSource<'_>>,
) -> CommandResult<Vec<TransactionWithTags>> {
Ok(Vec::new())
}
async fn get_transactions(
&self,
_filter: crate::services::transactions::types::inputs::TransactionFilter,
_tx: Option<&ConnectionSource<'_>>,
) -> CommandResult<Vec<TransactionWithTags>> {
Ok(Vec::new())
}
async fn get_transaction(
&self,
id: String,
_tx: Option<&ConnectionSource<'_>>,
) -> CommandResult<TransactionWithTags> {
let txn = create_mock_transaction(&id, "acc-1", "transfer_out", "100.00", "USD");
Ok(TransactionWithTags {
transaction: txn,
tags: Vec::new(),
})
}
async fn update_transaction(
&self,
_id: String,
_updates: crate::services::transactions::types::inputs::UpdateTransactionInput,
_tx: Option<&ConnectionSource<'_>>,
) -> CommandResult<TransactionWithTags> {
let txn = create_mock_transaction("txn-1", "acc-1", "transfer_out", "100.00", "USD");
Ok(TransactionWithTags {
transaction: txn,
tags: Vec::new(),
})
}
async fn delete_transaction(
&self,
_id: String,
_tx: Option<&ConnectionSource<'_>>,
) -> CommandResult<()> {
Ok(())
}
async fn bulk_delete_transactions(
&self,
_ids: Vec<String>,
_tx: Option<&ConnectionSource<'_>>,
) -> CommandResult<crate::services::transactions::types::outputs::BulkDeleteResult>
{
Ok(
crate::services::transactions::types::outputs::BulkDeleteResult {
deleted_count: 0,
failed_ids: Vec::new(),
},
)
}
async fn confirm_transaction(
&self,
_id: String,
_tx: Option<&ConnectionSource<'_>>,
) -> CommandResult<()> {
Ok(())
}
async fn get_transactions_needing_review(
&self,
_tx: Option<&ConnectionSource<'_>>,
) -> CommandResult<Vec<TransactionWithTags>> {
Ok(Vec::new())
}
async fn get_transaction_statistics(
&self,
_filter: crate::services::transactions::types::inputs::TransactionFilter,
_tx: Option<&ConnectionSource<'_>>,
) -> CommandResult<crate::services::transactions::types::inputs::TransactionStatistics>
{
Ok(
crate::services::transactions::types::inputs::TransactionStatistics {
total_income: "0".to_string(),
total_expense: "0".to_string(),
total_transfer_in: "0".to_string(),
total_transfer_out: "0".to_string(),
net_flow: "0".to_string(),
count_income: 0,
count_expense: 0,
count_transfer: 0,
total_count: 0,
},
)
}
async fn get_transactions_by_tags(
&self,
_tag_ids: Vec<String>,
_match_all: bool,
_tx: Option<&ConnectionSource<'_>>,
) -> CommandResult<Vec<TransactionWithTags>> {
Ok(Vec::new())
}
}
#[tokio::test]
async fn test_transfer_service_impl_new() {
let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection();
let transaction_service: Arc<dyn TransactionService> =
Arc::new(MockTransactionService { db: db.clone() });
let service = TransferServiceImpl::new(db, transaction_service);
// Just verify the service was created successfully
// The service doesn't expose its fields, but we can verify it implements ServiceTrait
assert!(service.on_app_start().await.is_ok());
}
#[tokio::test]
async fn test_get_transfers_empty() {
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![] as Vec<transfers::Model>])
.into_connection();
let transaction_service: Arc<dyn TransactionService> =
Arc::new(MockTransactionService { db: db.clone() });
let service = TransferServiceImpl::new(db, transaction_service);
let result = service.get_transfers(None, None, None, None).await;
assert!(result.is_ok());
let transfers = result.expect("Failed to get transfers");
assert!(transfers.is_empty());
}
#[tokio::test]
async fn test_get_transfers_with_account_filter() {
let mock_transfer = create_mock_transfer("xfer-1", "acc-1", "acc-2", "100.00", "100.00");
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![mock_transfer]])
.into_connection();
let transaction_service: Arc<dyn TransactionService> =
Arc::new(MockTransactionService { db: db.clone() });
let service = TransferServiceImpl::new(db, transaction_service);
let result = service
.get_transfers(Some("acc-1".to_string()), None, None, None)
.await;
assert!(result.is_ok());
let transfers = result.expect("Failed to get transfers");
assert_eq!(transfers.len(), 1);
assert_eq!(transfers[0].transfer.id, "xfer-1");
assert_eq!(transfers[0].transfer.from_account_id, "acc-1");
}
#[tokio::test]
async fn test_get_transfers_with_date_filter() {
let mock_transfer = create_mock_transfer("xfer-1", "acc-1", "acc-2", "100.00", "100.00");
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![mock_transfer]])
.into_connection();
let transaction_service: Arc<dyn TransactionService> =
Arc::new(MockTransactionService { db: db.clone() });
let service = TransferServiceImpl::new(db, transaction_service);
let result = service
.get_transfers(
None,
Some("2024-01-01".to_string()),
Some("2024-01-31".to_string()),
None,
)
.await;
assert!(result.is_ok());
let transfers = result.expect("Failed to get transfers");
assert_eq!(transfers.len(), 1);
}
#[tokio::test]
async fn test_delete_transfer_not_found() {
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![] as Vec<transfers::Model>])
.into_connection();
let transaction_service: Arc<dyn TransactionService> =
Arc::new(MockTransactionService { db: db.clone() });
let service = TransferServiceImpl::new(db, transaction_service);
let result = service
.delete_transfer("nonexistent".to_string(), None)
.await;
assert!(result.is_err());
let err = result.expect_err("Expected error");
assert!(err.to_string().contains("not found") || err.to_string().contains("Not found"));
}
#[tokio::test]
async fn test_create_transfer_input_validation() {
// Test that CreateTransferInput is properly constructed
let input = create_test_transfer_input();
assert_eq!(input.from_account_id, "acc-from-1");
assert_eq!(input.to_account_id, "acc-to-1");
assert_eq!(input.from_amount, "100.00000000");
assert_eq!(input.to_amount, "100.00000000");
assert_eq!(input.transfer_date, "2024-01-15");
assert_eq!(input.description, Some("Test transfer".to_string()));
}
#[tokio::test]
async fn test_create_transfer_different_currencies() {
let input = CreateTransferInput {
from_account_id: "acc-hkd".to_string(),
to_account_id: "acc-usd".to_string(),
from_amount: "1000.00000000".to_string(),
to_amount: "128.00000000".to_string(),
exchange_rate: Some("0.128".to_string()),
exchange_rate_source: Some("manual".to_string()),
fees: Some("10.00000000".to_string()),
description: Some("HKD to USD transfer".to_string()),
transfer_date: "2024-01-15".to_string(),
};
assert_eq!(input.exchange_rate, Some("0.128".to_string()));
assert_eq!(input.exchange_rate_source, Some("manual".to_string()));
assert_eq!(input.fees, Some("10.00000000".to_string()));
}
#[tokio::test]
async fn test_transfer_model_creation() {
let transfer = create_mock_transfer("xfer-test", "acc-a", "acc-b", "500.00", "500.00");
assert_eq!(transfer.id, "xfer-test");
assert_eq!(transfer.from_account_id, "acc-a");
assert_eq!(transfer.to_account_id, "acc-b");
assert_eq!(transfer.from_amount, "500.00");
assert_eq!(transfer.to_amount, "500.00");
assert!(!transfer.is_deleted);
assert_eq!(transfer.version, 1);
}
#[tokio::test]
async fn test_mock_transaction_service_create_transaction() {
let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection();
let service = MockTransactionService { db };
let input = CreateTransactionInput {
account_id: "acc-1".to_string(),
transaction_type: TransactionType::TransferOut,
gross_amount: "100.00".to_string(),
tax_amount: Some("0.00".to_string()),
net_amount: "100.00".to_string(),
currency: "USD".to_string(),
description: "Test".to_string(),
merchant: None,
notes: None,
receipt_paths: None,
transaction_date: "2024-01-15".to_string(),
tag_ids: vec!["tag-1".to_string()],
};
let result = service.create_transaction(input, None).await;
assert!(result.is_ok());
let txn_with_tags = result.unwrap();
assert_eq!(txn_with_tags.tags.len(), 1);
assert_eq!(txn_with_tags.tags[0], "tag-1");
assert_eq!(txn_with_tags.transaction.transaction_type, "transfer_out");
}
#[tokio::test]
async fn test_mock_transaction_service_delete_transaction() {
let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection();
let service = MockTransactionService { db };
let result = service
.delete_transaction("txn-123".to_string(), None)
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_service_trait_implementation() {
let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection();
let transaction_service: Arc<dyn TransactionService> =
Arc::new(MockTransactionService { db: db.clone() });
let service = TransferServiceImpl::new(db, transaction_service);
// Test that ServiceTrait is properly implemented
let result = service.on_app_start().await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_transfer_with_empty_description() {
let input = CreateTransferInput {
from_account_id: "acc-1".to_string(),
to_account_id: "acc-2".to_string(),
from_amount: "100.00".to_string(),
to_amount: "100.00".to_string(),
exchange_rate: None,
exchange_rate_source: None,
fees: None,
description: None,
transfer_date: "2024-01-15".to_string(),
};
assert!(input.description.is_none());
}
#[tokio::test]
async fn test_transfer_with_zero_fees() {
let input = CreateTransferInput {
from_account_id: "acc-1".to_string(),
to_account_id: "acc-2".to_string(),
from_amount: "100.00".to_string(),
to_amount: "100.00".to_string(),
exchange_rate: None,
exchange_rate_source: None,
fees: Some("0.00000000".to_string()),
description: Some("No fee transfer".to_string()),
transfer_date: "2024-01-15".to_string(),
};
assert_eq!(input.fees, Some("0.00000000".to_string()));
}
}

View File

@@ -0,0 +1,14 @@
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct CreateTransferInput {
pub from_account_id: String,
pub to_account_id: String,
pub from_amount: String,
pub to_amount: String,
pub exchange_rate: Option<String>,
pub exchange_rate_source: Option<String>,
pub fees: Option<String>,
pub description: Option<String>,
pub transfer_date: String,
}

View File

@@ -0,0 +1,2 @@
pub mod inputs;
pub mod outputs;

View File

@@ -0,0 +1,10 @@
use serde::Serialize;
use crate::db::entities::{transactions, transfers};
#[derive(Debug, Serialize)]
pub struct TransferWithTransactions {
pub transfer: transfers::Model,
pub from_transaction: Option<transactions::Model>,
pub to_transaction: Option<transactions::Model>,
}

View File

@@ -1,18 +1,111 @@
use std::sync::Arc;
use crate::{db::service::DbService, services::ServiceFactory};
use crate::{
db::service::DbService,
services::{
ServiceFactoryResult,
ServiceTrait,
accounts::service::AccountService,
exchange_rate::service::ExchangeRateService,
// goals::service::GoalService,
// reconciliations::service::ReconciliationService,
// scheduled::service::ScheduledTransactionService,
settings::service::SettingsService,
tags::service::TagService,
transactions::service::TransactionService,
transfers::service::TransferService,
},
};
pub struct AppState {
db: DbService,
account_service: Arc<dyn AccountService>,
transaction_service: Arc<dyn TransactionService>,
settings_service: Arc<dyn SettingsService>,
exchange_rate_service: Arc<dyn ExchangeRateService>,
tag_service: Arc<dyn TagService>,
transfer_service: Arc<dyn TransferService>,
// scheduled_service: Arc<dyn ScheduledTransactionService>,
// goal_service: Arc<dyn GoalService>,
// reconciliation_service: Arc<dyn ReconciliationService>,
}
impl AppState {
/// Create a new AppState with all services initialized
pub async fn new(db: DbService) -> Self {
let () = ServiceFactory::create_services(db.get_connection().clone());
Self { db }
pub async fn new(db: DbService, services: ServiceFactoryResult) -> Self {
Self {
db,
account_service: services.account_service,
transaction_service: services.transaction_service,
settings_service: services.settings_service,
exchange_rate_service: services.exchange_rate_service,
tag_service: services.tag_service,
transfer_service: services.transfer_service,
// scheduled_service: services.scheduled_service,
// goal_service: services.goal_service,
// reconciliation_service: services.reconciliation_service,
}
}
/// Get the database service
pub fn db(&self) -> &DbService {
&self.db
}
/// Get the account service
pub fn account_service(&self) -> &Arc<dyn AccountService> {
&self.account_service
}
/// Get the transaction service
pub fn transaction_service(&self) -> &Arc<dyn TransactionService> {
&self.transaction_service
}
/// Get the settings service
pub fn settings_service(&self) -> &Arc<dyn SettingsService> {
&self.settings_service
}
/// Get the exchange rate service
pub fn exchange_rate_service(&self) -> &Arc<dyn ExchangeRateService> {
&self.exchange_rate_service
}
/// Get the tag service
pub fn tag_service(&self) -> &Arc<dyn TagService> {
&self.tag_service
}
/// Get the transfer service
pub fn transfer_service(&self) -> &Arc<dyn TransferService> {
&self.transfer_service
}
// /// Get the scheduled transaction service
// pub fn scheduled_service(&self) -> &Arc<dyn ScheduledTransactionService> {
// &self.scheduled_service
// }
// /// Get the goal service
// pub fn goal_service(&self) -> &Arc<dyn GoalService> {
// &self.goal_service
// }
// /// Get the reconciliation service
// pub fn reconciliation_service(&self) -> &Arc<dyn ReconciliationService> {
// &self.reconciliation_service
// }
pub async fn on_app_start(&self) -> crate::errors::CommandResult<()> {
// Call on_app_start for all services
self.settings_service.on_app_start().await?;
self.exchange_rate_service.on_app_start().await?;
self.account_service.on_app_start().await?;
self.transaction_service.on_app_start().await?;
self.tag_service.on_app_start().await?;
self.transfer_service.on_app_start().await?;
// self.scheduled_service.on_app_start().await?;
// self.goal_service.on_app_start().await?;
// self.reconciliation_service.on_app_start().await?;
Ok(())
}
}