Compare commits
33 Commits
feature/ac
...
d60a573c64
| Author | SHA1 | Date | |
|---|---|---|---|
| d60a573c64 | |||
| e731c45a71 | |||
| bfbb771cbf | |||
| 30eb0b71cc | |||
| 4e4a656c88 | |||
| 3ff421c200 | |||
| 75efe5768a | |||
| 620df5780b | |||
| 6b987181a8 | |||
| bf04d8d2da | |||
| 7ffc3bac00 | |||
| 7448bbd5e0 | |||
| 8013f2ad61 | |||
| a0d5bae160 | |||
| f526d9ab2b | |||
| 5b3a29f615 | |||
| 49291002ac | |||
| 6f3c5ef106 | |||
| d57eeef78f | |||
| a6625fc55c | |||
| 9dc8166225 | |||
| 716223e45c | |||
| 8f18b8692f | |||
| e99feace0e | |||
| 51246ab378 | |||
| 07accb0265 | |||
| 3be04939c9 | |||
| ac66ae16aa | |||
| c280f7ff8b | |||
| acc0668392 | |||
| 88e8640386 | |||
| 15dfcd2e73 | |||
| 4e1d383285 |
30
.github/workflows/test.yml
vendored
Normal file
30
.github/workflows/test.yml
vendored
Normal 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
282
Cargo.lock
generated
@@ -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"
|
||||
|
||||
5
justfile
5
justfile
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
99
src-tauri/src/commands/accounts.rs
Normal file
99
src-tauri/src/commands/accounts.rs
Normal 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())
|
||||
}
|
||||
51
src-tauri/src/commands/exchange_rate.rs
Normal file
51
src-tauri/src/commands/exchange_rate.rs
Normal 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
|
||||
}
|
||||
@@ -1 +1,4 @@
|
||||
|
||||
pub mod accounts;
|
||||
pub mod exchange_rate;
|
||||
pub mod settings;
|
||||
pub mod transactions;
|
||||
|
||||
28
src-tauri/src/commands/settings.rs
Normal file
28
src-tauri/src/commands/settings.rs
Normal 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
|
||||
}
|
||||
66
src-tauri/src/commands/transactions.rs
Normal file
66
src-tauri/src/commands/transactions.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use crate::errors::CommandResult;
|
||||
use crate::services::transactions::types::inputs::{CreateTransactionInput, TransactionFilter, UpdateTransactionInput};
|
||||
use crate::services::transactions::types::outputs::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
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
pub mod entities;
|
||||
pub mod service;
|
||||
|
||||
mod connection;
|
||||
pub mod connection;
|
||||
mod migrations;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,35 @@ 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,
|
||||
// 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,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
2
src-tauri/src/services/accounts/mod.rs
Normal file
2
src-tauri/src/services/accounts/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod service;
|
||||
pub mod types;
|
||||
1601
src-tauri/src/services/accounts/service.rs
Normal file
1601
src-tauri/src/services/accounts/service.rs
Normal file
File diff suppressed because it is too large
Load Diff
185
src-tauri/src/services/accounts/types/inputs.rs
Normal file
185
src-tauri/src/services/accounts/types/inputs.rs
Normal 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>,
|
||||
}
|
||||
2
src-tauri/src/services/accounts/types/mod.rs
Normal file
2
src-tauri/src/services/accounts/types/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod inputs;
|
||||
pub mod outputs;
|
||||
7
src-tauri/src/services/accounts/types/outputs.rs
Normal file
7
src-tauri/src/services/accounts/types/outputs.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AccountBalance {
|
||||
pub amount: String,
|
||||
pub currency: String,
|
||||
}
|
||||
1
src-tauri/src/services/balance_calculator/mod.rs
Normal file
1
src-tauri/src/services/balance_calculator/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod service;
|
||||
434
src-tauri/src/services/balance_calculator/service.rs
Normal file
434
src-tauri/src/services/balance_calculator/service.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
462
src-tauri/src/services/exchange_rate/adapters/exchange_api.rs
Normal file
462
src-tauri/src/services/exchange_rate/adapters/exchange_api.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
233
src-tauri/src/services/exchange_rate/adapters/mod.rs
Normal file
233
src-tauri/src/services/exchange_rate/adapters/mod.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
5
src-tauri/src/services/exchange_rate/mod.rs
Normal file
5
src-tauri/src/services/exchange_rate/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod adapters;
|
||||
pub mod service;
|
||||
|
||||
// reexporting for easier access
|
||||
pub use adapters::ExchangeRateAdapterInfo;
|
||||
565
src-tauri/src/services/exchange_rate/service.rs
Normal file
565
src-tauri/src/services/exchange_rate/service.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,49 @@
|
||||
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 settings;
|
||||
pub mod transactions;
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
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 transaction_service = Arc::new(transactions::service::TransactionServiceImpl::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,
|
||||
);
|
||||
ServiceFactoryResult {
|
||||
account_service,
|
||||
transaction_service,
|
||||
exchange_rate_service,
|
||||
settings_service,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
2
src-tauri/src/services/settings/mod.rs
Normal file
2
src-tauri/src/services/settings/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod service;
|
||||
pub mod types;
|
||||
635
src-tauri/src/services/settings/service.rs
Normal file
635
src-tauri/src/services/settings/service.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
83
src-tauri/src/services/settings/types/date_of_week.rs
Normal file
83
src-tauri/src/services/settings/types/date_of_week.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
43
src-tauri/src/services/settings/types/display_mode.rs
Normal file
43
src-tauri/src/services/settings/types/display_mode.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
43
src-tauri/src/services/settings/types/language.rs
Normal file
43
src-tauri/src/services/settings/types/language.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
6
src-tauri/src/services/settings/types/mod.rs
Normal file
6
src-tauri/src/services/settings/types/mod.rs
Normal 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;
|
||||
107
src-tauri/src/services/settings/types/settings.rs
Normal file
107
src-tauri/src/services/settings/types/settings.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
43
src-tauri/src/services/settings/types/theme.rs
Normal file
43
src-tauri/src/services/settings/types/theme.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
39
src-tauri/src/services/settings/types/view.rs
Normal file
39
src-tauri/src/services/settings/types/view.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
2
src-tauri/src/services/transactions/mod.rs
Normal file
2
src-tauri/src/services/transactions/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod service;
|
||||
pub mod types;
|
||||
1532
src-tauri/src/services/transactions/service.rs
Normal file
1532
src-tauri/src/services/transactions/service.rs
Normal file
File diff suppressed because it is too large
Load Diff
80
src-tauri/src/services/transactions/types/inputs.rs
Normal file
80
src-tauri/src/services/transactions/types/inputs.rs
Normal 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)]
|
||||
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,
|
||||
}
|
||||
2
src-tauri/src/services/transactions/types/mod.rs
Normal file
2
src-tauri/src/services/transactions/types/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod inputs;
|
||||
pub mod outputs;
|
||||
15
src-tauri/src/services/transactions/types/outputs.rs
Normal file
15
src-tauri/src/services/transactions/types/outputs.rs
Normal 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>,
|
||||
}
|
||||
@@ -1,18 +1,63 @@
|
||||
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, settings::service::SettingsService,
|
||||
transactions::service::TransactionService,
|
||||
},
|
||||
};
|
||||
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>,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
/// 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
|
||||
}
|
||||
|
||||
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?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user