70 Commits

Author SHA1 Message Date
d60a573c64 feat(transactions): refactor balance calculation using BalanceCalculator service 2026-02-25 10:58:23 +00:00
e731c45a71 feat(transactions): implement transaction management commands 2026-02-25 10:58:03 +00:00
bfbb771cbf feat(transactions): add input and output types for transaction management
- Introduced `CreateTransactionInput`, `BulkCreateTransactionInput`, `UpdateTransactionInput`, and `TransactionFilter` structs for handling transaction creation and updates.
- Added `BulkDeleteInput` and `BulkDeleteResult` structs for bulk deletion of transactions.
- Created `TransactionStatistics` struct to encapsulate transaction statistics data.
- Established a new module for transaction types in `mod.rs`.
- Implemented `TransactionWithTags` struct in outputs for returning transactions with associated tags.
- Updated `AppState` to include a transaction service, ensuring it is initialized and accessible.
2026-02-25 10:57:48 +00:00
30eb0b71cc feat: add balance calculator service module and transaction type handling 2026-02-25 10:57:24 +00:00
4e4a656c88 Merge pull request 'feat: implement account management commands and services' (#9) from feature/accounts-service into master
All checks were successful
Lint / lint-frontend (push) Successful in 20s
Test / test-crates (push) Successful in 1m46s
Lint / lint-crates (push) Successful in 2m17s
Reviewed-on: #9
2026-02-25 18:04:48 +08:00
3ff421c200 feat: extend setting type handling to include Language, View, DisplayMode, Theme, and DateOfWeek
All checks were successful
Lint / lint-frontend (pull_request) Successful in 38s
Test / test-crates (pull_request) Successful in 1m48s
Lint / lint-crates (pull_request) Successful in 2m16s
2026-02-25 09:59:26 +00:00
75efe5768a fix: update database connection options to use ConnectOptions for improved logging 2026-02-25 09:58:59 +00:00
620df5780b refactor: improve HEX_COLOR_PATTERN initialization with Clippy expectation
All checks were successful
Lint / lint-crates (pull_request) Successful in 1m5s
Lint / lint-frontend (pull_request) Successful in 16s
Test / test-crates (pull_request) Successful in 1m25s
2026-02-23 08:51:51 +00:00
6b987181a8 refactor: improve formatting of apply_transaction_to_balance function and add TODO for transaction service integration
Some checks failed
Lint / lint-crates (pull_request) Failing after 3m2s
Lint / lint-frontend (pull_request) Successful in 15s
Test / test-crates (pull_request) Successful in 1m47s
2026-02-23 08:48:54 +00:00
bf04d8d2da feat: add recalculate_account_balance command and enhance account validation 2026-02-23 08:48:43 +00:00
7ffc3bac00 feat: enhance account management with filtering and validation improvements
All checks were successful
Lint / lint-frontend (pull_request) Successful in 21s
Lint / lint-crates (pull_request) Successful in 3m6s
Test / test-crates (pull_request) Successful in 4m6s
2026-02-23 04:53:37 +00:00
7448bbd5e0 feat: implement account management commands and services
All checks were successful
Lint / lint-frontend (pull_request) Successful in 19s
Test / test-crates (pull_request) Successful in 1m33s
Lint / lint-crates (pull_request) Successful in 2m1s
- Added account commands for creating, retrieving, updating, archiving, deleting accounts, and fetching account balances.
- Created account service with async methods for account operations.
- Defined input and output types for account operations.
- Integrated account service into the application state and service factory.
- Added tests for account service methods to ensure functionality and correctness.
2026-02-21 03:14:02 +00:00
8013f2ad61 Merge pull request 'feature/exchange-rate-service' (#8) from feature/exchange-rate-service into master
All checks were successful
Lint / lint-frontend (push) Successful in 19s
Test / test-crates (push) Successful in 1m30s
Lint / lint-crates (push) Successful in 1m58s
Reviewed-on: #8
2026-02-21 10:26:35 +08:00
a0d5bae160 feat: implement on_app_start to set default exchange rate adapter in settings
All checks were successful
Lint / lint-frontend (pull_request) Successful in 20s
Test / test-crates (pull_request) Successful in 1m29s
Lint / lint-crates (pull_request) Successful in 1m55s
2026-02-21 02:09:52 +00:00
f526d9ab2b fmt: fix formatting
All checks were successful
Lint / lint-frontend (pull_request) Successful in 20s
Test / test-crates (pull_request) Successful in 1m28s
Lint / lint-crates (pull_request) Successful in 1m55s
2026-02-20 14:29:56 +00:00
5b3a29f615 feat: implement exchange rate commands and service integration
Some checks failed
Lint / lint-frontend (pull_request) Successful in 20s
Test / test-crates (pull_request) Successful in 1m35s
Lint / lint-crates (pull_request) Failing after 1m59s
2026-02-20 14:26:06 +00:00
49291002ac test: improve error handling in assertions for currency data fetching tests 2026-02-20 14:22:56 +00:00
6f3c5ef106 feat: refactor ExchangeRateService to use DatabaseConnection and update service creation
Some checks failed
Lint / lint-frontend (pull_request) Successful in 21s
Test / test-crates (pull_request) Successful in 1m30s
Lint / lint-crates (pull_request) Failing after 1m58s
2026-02-20 13:40:05 +00:00
d57eeef78f fix: update exchange rate data types to use f64
All checks were successful
Lint / lint-frontend (pull_request) Successful in 21s
Test / test-crates (pull_request) Successful in 1m30s
Lint / lint-crates (pull_request) Successful in 1m57s
2026-02-20 13:00:14 +00:00
a6625fc55c feat: enhance error logging for currency fetching and adapter retrieval
All checks were successful
Lint / lint-frontend (pull_request) Successful in 20s
Test / test-crates (pull_request) Successful in 1m30s
Lint / lint-crates (pull_request) Successful in 1m58s
2026-02-20 10:19:29 +00:00
9dc8166225 feat: add get_current_adapter method to ExchangeRateService implementation
All checks were successful
Lint / lint-frontend (pull_request) Successful in 21s
Lint / lint-crates (pull_request) Successful in 3m4s
Test / test-crates (pull_request) Successful in 4m12s
2026-02-20 10:10:50 +00:00
716223e45c feat: add get_setting_with_prefix method to SettingsService implementation 2026-02-20 10:01:43 +00:00
8f18b8692f feat: implement default adapter retrieval and update settings in ExchangeRateService 2026-02-20 09:59:07 +00:00
e99feace0e Implement ExchangeRateApiAdapter and related service functionality
- Added ExchangeRateApiAdapter for fetching exchange rates from ExchangeRate-API.
- Implemented ExchangeRateService with caching and database storage for exchange rates.
- Created a modular structure for exchange rate adapters.
- Added tests for the new adapter and service functionalities, ensuring correct behavior and error handling.
- Included support for fetching and storing exchange rates, as well as retrieving supported currencies.
2026-02-20 09:47:36 +00:00
51246ab378 Merge pull request 'feature/settings-service' (#7) from feature/settings-service into master
All checks were successful
Lint / lint-frontend (push) Successful in 21s
Test / test-crates (push) Successful in 1m22s
Lint / lint-crates (push) Successful in 1m48s
Reviewed-on: #7
2026-02-20 14:06:13 +08:00
07accb0265 feat: add get_setting method to SettingsService and implement in SettingsServiceImpl
All checks were successful
Lint / lint-frontend (pull_request) Successful in 1m5s
Lint / lint-crates (pull_request) Successful in 4m32s
Test / test-crates (pull_request) Successful in 5m35s
2026-02-20 05:33:55 +00:00
3be04939c9 feat: implement settings service commands and integrate into app state
Some checks failed
Lint / lint-frontend (pull_request) Successful in 21s
Lint / lint-crates (pull_request) Failing after 0s
Test / test-crates (pull_request) Successful in 3m28s
2026-02-20 05:15:14 +00:00
ac66ae16aa feat: add GitHub Actions workflow for running tests on push and pull request 2026-02-20 04:50:29 +00:00
c280f7ff8b feat: add tokio as a dev dependency and implement unit tests for settings service 2026-02-20 04:40:11 +00:00
acc0668392 feat: implement settings service with CRUD operations and integrate into app state 2026-02-19 03:50:37 +00:00
88e8640386 feat: update Cargo.toml to use Rust edition 2024 and add struct_iterable dependency; update Cargo.lock with new packages 2026-02-19 03:50:11 +00:00
15dfcd2e73 feat: enhance database connection handling with ConnectionSource enum and implement ConnectionTrait for better abstraction 2026-02-19 02:44:58 +00:00
4e1d383285 Merge pull request 'feature/actions' (#6) from feature/actions into master
All checks were successful
Lint / lint-frontend (push) Successful in 22s
Lint / lint-crates (push) Successful in 1m18s
Reviewed-on: finwise/finewise#6
2026-02-17 11:34:19 +08:00
751c50b9ae feat: update lint workflow to check code formatting in src-tauri directory and refactor state.rs for cleaner imports
All checks were successful
Lint / lint-frontend (pull_request) Successful in 1m20s
Lint / lint-crates (pull_request) Successful in 4m16s
2026-02-17 03:24:08 +00:00
b5bc2b7d1a feat: add Dockerfile for CI environment setup and update justfile for CI image management 2026-02-17 02:51:36 +00:00
3f9f713261 feat: update devcontainer configuration and add setup action for Rust environment 2026-02-17 02:51:21 +00:00
GW_MC
70e7b00a15 feat: add Docker-in-Docker and act features to devcontainer configuration 2026-02-16 12:53:05 +00:00
GW_MC
763b69aefa feat: add setup action for Rust environment and linting workflow 2026-02-16 12:52:52 +00:00
GW_MC
21c15d45e4 feat: add noVNC wrapper for local-scaling and enhance VNC startup script 2026-02-16 12:27:05 +00:00
0eb592458b Merge pull request 'structure-setup' (#5) from structure-setup into master
Reviewed-on: finwise/finewise#5
2026-02-16 20:26:37 +08:00
GW_MC
0daa77d757 feat: enhance error handling in database connection functions 2026-02-16 12:23:20 +00:00
GW_MC
de9fc621ec feat: attach console logging for better debugging in main.tsx 2026-02-16 09:15:59 +00:00
GW_MC
31885dd67e feat: add logging support with tauri-plugin-log and setup app state management 2026-02-16 08:25:49 +00:00
GW_MC
94cf1d1d68 feat: implement ServiceFactory for service instance creation in AppState 2026-02-16 08:12:19 +00:00
GW_MC
a95e03f3a7 feat: add initial mod.rs, services, and state files for project structure 2026-02-16 08:04:19 +00:00
GW_MC
a88f2dc355 feat: enforce safety and linting rules in lib.rs 2026-02-16 08:00:04 +00:00
GW_MC
bd6d752fbb feat: add From implementation for chrono::ParseError in AppError 2026-02-16 07:59:30 +00:00
GW_MC
af97ef24fc feat: add async-trait and sha2 dependencies to Cargo.toml 2026-02-16 07:59:18 +00:00
9d5c272c60 Merge pull request 'feat: implement custom error handling with AppError enum and update dependencies' (#4) from feature/error-handling into master
Reviewed-on: finwise/finewise#4
2026-02-16 13:26:10 +08:00
GW_MC
bcc023cc5f feat: implement custom error handling with AppError enum and update dependencies 2026-02-16 05:11:19 +00:00
d1e9e92d00 Merge pull request 'database-setup' (#3) from database-setup into master
Reviewed-on: finwise/finewise#3
2026-02-16 11:08:05 +08:00
GW_MC
309f574d45 fix: add 'allow(dead_code)' attribute to multiple SeaORM entity models 2026-02-16 02:55:58 +00:00
GW_MC
032286eef9 Merge branch 'master' into database-setup 2026-02-16 02:28:01 +00:00
d6423791d8 Merge pull request 'setup-tauri' (#2) from setup-tauri into master
Reviewed-on: finwise/finewise#2
2026-02-16 10:20:43 +08:00
GW_MC
7aaf438018 fix: remove dev command for running Tauri with xvfb 2026-02-16 02:17:18 +00:00
GW_MC
ca4ffa0a35 Merge branch 'master' into setup-tauri 2026-02-16 02:10:33 +00:00
GW_MC
850a9fa85f fix: add dev command for running Tauri with VNC display configuration 2026-02-15 16:17:45 +00:00
GW_MC
16a356cc6d fix: remove unnecessary X11 volume mount from devcontainer configuration 2026-02-15 16:02:13 +00:00
GW_MC
ba462365bc fix: add missing X11 volume mount to devcontainer configuration 2026-02-15 15:36:13 +00:00
GW_MC
edf3dc439e fix: remove unnecessary X11 volume mount from devcontainer configuration 2026-02-15 15:26:55 +00:00
GW_MC
0c04030357 fix: remove unnecessary volume mount for X11 in devcontainer configuration 2026-02-15 15:25:54 +00:00
GW_MC
7fcf9e701f fix: correct mount syntax for X11 Unix socket in devcontainer configuration 2026-02-15 15:22:50 +00:00
GW_MC
20eebf25b1 chore: add target directory to .gitignore 2026-02-15 15:13:02 +00:00
GW_MC
606959d687 Merge branch 'master' into setup-tauri 2026-02-15 15:12:10 +00:00
GW_MC
aeac78e7f2 chore: add release and dev profile configurations to Cargo.toml 2026-02-15 15:01:39 +00:00
GW_MC
c19272076f feat: add VNC support with noVNC and update Dockerfile and README 2026-02-15 15:01:32 +00:00
GW_MC
83e9d679ae enhance devcontainer setup with VNC support and noVNC integration 2026-02-14 07:21:08 +00:00
GW_MC
692e38e1d2 feat: add database connection and migration functionality
- Updated Cargo.toml to include necessary dependencies for SeaORM and database handling.
- Implemented database connection establishment in `src-tauri/src/db/connection.rs`.
- Created database entities for accounts, exchange rates, goals, transactions, and more using SeaORM.
- Added migration handling in `src-tauri/src/db/migrations.rs`.
- Introduced a database service layer in `src-tauri/src/db/service.rs` for managing database connections and migrations.
- Updated the main library file to include the new database module.
2026-02-14 06:03:58 +00:00
GW_MC
39fcf26ea9 feat: initialize workspace and add migration crate
- Created a new Cargo.toml for the workspace, defining members and dependencies.
- Added a migration crate with its own Cargo.toml and README for CLI usage.
- Implemented migration logic for creating tables: accounts, settings, exchange rates, goals, tags, scheduled transactions, transactions, reconciliations, transfers, transaction tags, goal rules, goal progress, and scheduled instances.
- Added main entry point for the migration CLI.
- Included a Justfile for initializing the environment and managing database migrations.
2026-02-14 06:03:33 +00:00
GW_MC
e4dc49310e update tauri configuration for Android and iOS bundle settings 2026-02-13 12:33:23 +00:00
90 changed files with 18308 additions and 38 deletions

View File

@@ -3,7 +3,7 @@ FROM mcr.microsoft.com/devcontainers/typescript-node:24-bookworm
# Arguments for the non-root user (provided by VS Code remote extensions)
ARG USERNAME=node
# Install system packages required by Tauri on Linux
# Install system packages required by Tauri on Linux and packages for VNC/noVNC
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
git \
@@ -19,7 +19,17 @@ RUN apt-get update \
libxdo-dev \
libssl-dev \
libayatana-appindicator3-dev \
libgdk-pixbuf2.0-dev \
librsvg2-dev \
xvfb \
xauth \
dbus-x11 \
xfce4 \
xfce4-goodies \
x11vnc \
websockify \
python3 \
python3-pip \
&& rm -rf /var/lib/apt/lists/*
# Install rustup and the stable toolchain into the non-root user's home
@@ -46,3 +56,23 @@ USER ${USERNAME}
RUN cargo install --locked just || true
WORKDIR /workspace
# Temporarily switch back to root to install noVNC into /opt (root-owned)
USER root
# clone noVNC so we can proxy VNC over WebSockets (browser access)
RUN git clone https://github.com/novnc/noVNC.git /opt/noVNC \
&& git clone https://github.com/novnc/websockify.git /opt/noVNC/utils/websockify \
&& chown -R ${USERNAME}:${USERNAME} /opt/noVNC
# Ensure novnc scripts are executable
RUN chmod +x /opt/noVNC/utils/novnc_proxy || true
# Return to the non-root user for the rest of the build
USER ${USERNAME}
# copy the VNC start script (added in devcontainer) and make it executable
COPY --chown=${USERNAME}:${USERNAME} start-vnc.sh /workspace/.devcontainer/start-vnc.sh
RUN chmod +x /workspace/.devcontainer/start-vnc.sh
EXPOSE 5900 6080

View File

@@ -15,5 +15,18 @@ How to use
Notes
- If you need additional system libraries for your distribution, edit `Dockerfile`.
- Forwarded port: `5173` (Vite dev server).
- `just` is available in the container; run `just` to execute repository tasks.
- Forwarded ports:
- `5173` (Vite dev server)
- `5900` (VNC server - direct VNC connection)
- `6080` (noVNC web interface - access via browser)
- `just` is available in the container; run `just` to execute repository tasks.
VNC / noVNC (Remote Desktop)
- This devcontainer includes a headless X11 environment with XFCE4 desktop.
- To start VNC: Run `just start-vnc` in the terminal
- To stop VNC: Run `just stop-vnc`
- Access the desktop via:
1. **Browser (recommended)**: http://localhost:6080/vnc.html
2. **VNC client**: Connect to `localhost:5900` with password `devpass`
- Default VNC password: `devpass` (set via `VNC_PASSWORD` environment variable)
- If you see "Port 6080 already in use", the noVNC service is already running.

View File

@@ -2,6 +2,11 @@
"name": "Finwise — Tauri Devcontainer",
"build": {
"dockerfile": "Dockerfile"
},"features": {
// Add Docker-in-Docker to allow act to run containers
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
// Add act
"ghcr.io/devcontainers-extra/features/act:1": {}
},
"customizations": {
"settings": {
@@ -10,7 +15,28 @@
"extensions": ["rust-lang.rust-analyzer", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint", "nefrob.vscode-just-syntax"]
},
"postCreateCommand": "corepack enable && corepack prepare pnpm@latest --activate && pnpm install && cd src-tauri && cargo fetch && cd -",
"forwardPorts": [5173],
"forwardPorts": [5173, 5900, 6080],
"portsAttributes": {
"5173": {
"label": "Vite Dev Server",
"onAutoForward": "notify"
},
"5900": {
"label": "VNC Server",
"onAutoForward": "silent"
},
"6080": {
"label": "noVNC Web (VNC via Browser)",
"protocol": "http"
}
},
"remoteEnv": { "VNC_PASSWORD": "devpass", "DISPLAY": "${localEnv:DISPLAY}" },
"runArgs": [
"-e",
"DISPLAY=${localEnv:DISPLAY}"
],
"mounts": [
"source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=cached"
],

View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
# Manual wrapper to start the VNC services on demand.
# Use this instead of automatic postStartCommand.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="${SCRIPT_DIR%/.*}/.."
LOGDIR=/tmp
OUT_LOG="$LOGDIR/start-vnc.manual.log"
echo "Starting VNC services (logs -> $OUT_LOG)"
# Ensure underlying start script exists
if [ ! -x "$SCRIPT_DIR/start-vnc.sh" ] && [ -f "$SCRIPT_DIR/start-vnc.sh" ]; then
chmod +x "$SCRIPT_DIR/start-vnc.sh" || true
fi
if [ ! -f "$SCRIPT_DIR/start-vnc.sh" ]; then
echo "Error: start-vnc.sh not found in $SCRIPT_DIR"
exit 1
fi
# Run the original script in the background and stream logs to the terminal
nohup bash "$SCRIPT_DIR/start-vnc.sh" >"$OUT_LOG" 2>&1 &
PID=$!
echo "start-vnc.sh started with PID $PID"
echo "Tailing logs (press Ctrl-C to stop tailing):"
sleep 1
tail -n +1 -f "$OUT_LOG"

View File

@@ -0,0 +1,27 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>noVNC (local-scaling wrapper)</title>
<script>
// Try several possible setting keys used by different noVNC versions
try {
localStorage.setItem('resize', 'scale');
localStorage.setItem('scaleViewport', 'true');
localStorage.setItem('scalingMode', 'local');
localStorage.setItem('preferLocalScaling', 'true');
localStorage.setItem('viewOnly', localStorage.getItem('viewOnly') || 'false');
} catch (e) {
// ignore
}
// Load the original noVNC UI (we will rename the original to vnc.orig.html)
var orig = 'vnc.orig.html' + window.location.search + window.location.hash;
// Use replace so back button doesn't loop
window.location.replace(orig);
</script>
</head>
<body>
<p>Applying local-scaling defaults and loading noVNC...</p>
</body>
</html>

138
.devcontainer/start-vnc.sh Executable file
View File

@@ -0,0 +1,138 @@
#!/usr/bin/env bash
set -euo pipefail
# Start a headless X server, a lightweight desktop, x11vnc and noVNC proxy.
# Runs as the container user (configured via devcontainer.json remoteUser).
VNC_PASS=${VNC_PASSWORD:-devpass}
VNC_DIR="$HOME/.vnc"
mkdir -p "$VNC_DIR"
if [ ! -f "$VNC_DIR/passwd" ]; then
echo "Creating VNC password"
x11vnc -storepasswd "$VNC_PASS" "$VNC_DIR/passwd"
fi
# Prefer using an existing X server (host) when available. Set USE_EXISTING_DISPLAY=true
# to force using the existing DISPLAY; otherwise, if a host DISPLAY is propagated into the
# container (via devcontainer runArgs / mounts), prefer that instead of starting Xvfb.
if [ "${USE_EXISTING_DISPLAY:-}" = "true" ] || { [ -n "${DISPLAY:-}" ] && [ -d "/tmp/.X11-unix" ]; }; then
echo "Using existing X server on ${DISPLAY:-<unset>} (X11 forwarding to host)"
export USE_EXISTING_DISPLAY=true
else
echo "Prefer Xvfb — starting Xvfb :1"
if command -v Xvfb >/dev/null 2>&1; then
# Define virtual screen size once; will try to confirm at runtime
# using xdpyinfo and fall back to these values if xdpyinfo isn't available.
SCREEN_W=1280
SCREEN_H=800
# Use a slightly taller virtual screen so desktop panels/toolbars
# don't cause the noVNC client to show an unexpected area below
# the visible display; use variables so we can clip x11vnc to
# exactly the same region and avoid framebuffer artifacts.
Xvfb :1 -screen 0 "${SCREEN_W}x${SCREEN_H}x24" >/tmp/xvfb.log 2>&1 &
export DISPLAY=:1
else
echo "Warning: Xvfb not available; expecting an X server on DISPLAY"
fi
fi
sleep 1
echo "Starting desktop session (xfce)"
# startxfce4 backgrounds itself; if not available fall back to openbox
if command -v startxfce4 >/dev/null 2>&1; then
startxfce4 >/tmp/xfce4.log 2>&1 &
else
if command -v openbox-session >/dev/null 2>&1; then
openbox-session >/tmp/openbox.log 2>&1 &
fi
fi
sleep 1
echo "Starting x11vnc on ${DISPLAY:-:1}"
if [ "${USE_EXISTING_DISPLAY:-}" != "true" ]; then
# Query the actual display size (xdpyinfo) to compute a precise clip region
# and avoid sending framebuffer rows outside the visible desktop. Disable
# client-side caching which can sometimes produce visual artifacts.
GEOM=""
if command -v xdpyinfo >/dev/null 2>&1; then
GEOM=$(xdpyinfo -display "${DISPLAY:-:1}" 2>/dev/null | awk '/dimensions:/ {print $2}') || true
fi
if [ -n "$GEOM" ]; then
# GEOM looks like WxH (e.g. 1280x800)
W=${GEOM%x*}
H=${GEOM#*x}
else
W=${SCREEN_W}
H=${SCREEN_H}
fi
echo "Starting x11vnc with clip ${W}x${H}+0+0 (display ${DISPLAY:-:1})"
x11vnc -display "${DISPLAY:-:1}" -rfbauth "$VNC_DIR/passwd" -forever -shared -rfbport 5900 -clip ${W}x${H}+0+0 -bg || true
else
echo "USE_EXISTING_DISPLAY=true — skipping x11vnc (display forwarded to host)"
fi
sleep 1
# Locate noVNC installation. Prefer /opt/noVNC, then workspace/noVNC.
NOVNC_DIR=""
if [ -d /opt/noVNC ]; then
NOVNC_DIR="/opt/noVNC"
elif [ -d "$PWD/noVNC" ]; then
NOVNC_DIR="$PWD/noVNC"
# Ensure it's available under /opt for tools that expect it there
if [ ! -e /opt/noVNC ]; then
mkdir -p /opt
ln -s "$NOVNC_DIR" /opt/noVNC || true
fi
fi
# Start the noVNC proxy only when we are running an internal VNC server on port 5900
if [ -n "$NOVNC_DIR" ] && [ "${USE_EXISTING_DISPLAY:-}" != "true" ]; then
echo "Found noVNC at $NOVNC_DIR"
# If a wrapper exists in the devcontainer, install it into the noVNC web
# root so we can set localStorage defaults (e.g. prefer local scaling)
WRAPPER="${SCRIPT_DIR:-$(pwd)}/novnc/vnc-wrapper.html"
if [ -f "$WRAPPER" ]; then
# Backup original vnc.html once and replace with wrapper
if [ -f "$NOVNC_DIR/vnc.html" ] && [ ! -f "$NOVNC_DIR/vnc.orig.html" ]; then
echo "Backing up original noVNC vnc.html to vnc.orig.html and installing wrapper"
cp "$NOVNC_DIR/vnc.html" "$NOVNC_DIR/vnc.orig.html" || true
cp "$WRAPPER" "$NOVNC_DIR/vnc.html" || true
fi
fi
echo "Waiting for VNC server on localhost:5900..."
ATTEMPTS=0
until ss -ltnp | grep -q ':5900' || [ $ATTEMPTS -ge 10 ]; do
ATTEMPTS=$((ATTEMPTS+1))
sleep 1
done
if ss -ltnp | grep -q ':5900'; then
# If port 6080 is already bound, report the owner and skip starting a new proxy.
if ss -ltnp | grep -q ':6080'; then
echo "Port 6080 already in use. Current listener:"
ss -ltnp | egrep ':6080' || true
echo "Skipping starting another noVNC proxy to avoid address-in-use errors."
else
echo "Starting noVNC proxy on port 6080"
# Prefer the bundled novnc_proxy script; pass explicit --web and bind address
NOVNC_PROXY="$NOVNC_DIR/utils/novnc_proxy"
if [ -x "$NOVNC_PROXY" ]; then
nohup "$NOVNC_PROXY" --vnc localhost:5900 --listen 0.0.0.0:6080 --web "$NOVNC_DIR" >/tmp/novnc.log 2>&1 &
else
nohup python3 "$NOVNC_DIR/utils/novnc_proxy" --vnc localhost:5900 --listen 0.0.0.0:6080 --web "$NOVNC_DIR" >/tmp/novnc.log 2>&1 &
fi
fi
else
echo "VNC server not listening on :5900, skipping noVNC startup. See logs for details."
fi
else
if [ "${USE_EXISTING_DISPLAY:-}" = "true" ]; then
echo "Using host X11 forwarding; skipping noVNC/websockify startup."
fi
fi
echo "VNC service started. Connect to :5900 or open http://<host>:6080/vnc.html"

37
.github/Dockerfile vendored Normal file
View File

@@ -0,0 +1,37 @@
# This Dockerfile sets up the environment for ci.
# requires at least bookworm for the required versions of webkit2gtk and gtk3
FROM node:24-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
ca-certificates \
pkg-config \
libgtk-3-dev \
libgdk-pixbuf2.0-dev \
libwebkit2gtk-4.1-dev \
build-essential \
curl \
wget \
file \
libxdo-dev \
libssl-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
libgdk-pixbuf2.0-dev \
xfce4 \
xfce4-goodies \
&& rm -rf /var/lib/apt/lists/*
# Install rustup and the stable toolchain
ENV RUSTUP_HOME=/root/.rustup \
CARGO_HOME=/root/.cargo \
PATH=/root/.cargo/bin:/usr/local/cargo/bin:$PATH
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs -o /tmp/rustup-init.sh \
&& chmod +x /tmp/rustup-init.sh \
&& /tmp/rustup-init.sh -y \
&& rm /tmp/rustup-init.sh
# Enable Corepack (for pnpm) and install latest pnpm shim
RUN corepack enable \
&& corepack prepare pnpm@latest --activate

66
.github/actions/setup-rust/action.yml vendored Normal file
View File

@@ -0,0 +1,66 @@
name: 'Setup Rust environment'
description: 'Composite action to checkout the repo, restore cargo caches and set up the Rust toolchain. Use this from job steps to keep setup DRY across jobs.'
inputs:
toolchain:
description: 'Rust toolchain to install'
required: false
default: 'stable'
override:
description: 'Whether to override the default toolchain'
required: false
default: 'true'
components:
description: 'Comma-separated list of additional rust components to install'
required: false
default: 'clippy, rustfmt'
runs:
using: 'composite'
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo index
uses: actions/cache@v4
with:
path: ~/.cargo/index
key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
- name: Sanitize components input
shell: bash
run: echo "SANITIZED_COMPONENTS=${{ inputs.components }}" | sed -E 's/, ?| /-/g' >> $GITHUB_ENV
- name: Cache Rust toolchain
uses: actions/cache@v4
with:
path: ~/.rustup
# Key includes the OS and the toolchain version (e.g., 'stable')
key: ${{ runner.os }}-rustup-${{ hashFiles('rust-toolchain.toml') }}-v1-${{ inputs.toolchain }}-${{ env.SANITIZED_COMPONENTS }}
restore-keys: |
${{ runner.os }}-rustup-
- name: Cache cargo build (target)
uses: actions/cache@v4
with:
path: target
key: ${{ runner.os }}-cargo-build-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-build-${{ hashFiles('**/Cargo.lock') }}
- name: Set up rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ inputs.toolchain }}
override: ${{ inputs.override }}
components: ${{ inputs.components }}

61
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,61 @@
name: Lint
on:
push:
branches:
- master
pull_request:
branches:
- master
workflow_dispatch:
jobs:
lint-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
with:
components: clippy, rustfmt
- name: Run clippy
run: cargo clippy --all-features
- name: Check code formatting
run: |
cd src-tauri
cargo fmt -- --check
lint-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- uses: pnpm/action-setup@v4
with:
version: 10
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: 'pnpm'
cache-dependency-path: pnpm-lock.yaml
- name: Install dependencies
run: |
pnpm install
- name: Run frontend linter
run: |
pnpm lint

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

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

3
.gitignore vendored
View File

@@ -25,3 +25,6 @@ dist-ssr
#pnpm
.pnpm-store
# target
target/

7271
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

15
Cargo.toml Normal file
View File

@@ -0,0 +1,15 @@
[workspace]
members = ["crates/*", "src-tauri"]
resolver = "2"
[workspace.package]
edition = "2021"
rust-version = "1.85.0"
[workspace.dependencies]
sea-orm = { version = "^2.0.0-rc", features = ["sqlx-sqlite", "runtime-tokio-native-tls"] }
sea-orm-migration = { version = "^2.0.0-rc", features = ["runtime-tokio-native-tls", "sqlx-sqlite"] }
tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread"] }
chrono = { version = "0.4.43", features = ["serde"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View File

@@ -0,0 +1,18 @@
[package]
name = "migration"
version = "0.1.0"
edition = "2021"
rust-version = "1.85.0"
publish = false
[lib]
name = "migration"
path = "src/lib.rs"
[[bin]]
name = "migration-cli"
path = "src/main.rs"
[dependencies]
tokio = { workspace = true }
sea-orm-migration = { workspace = true }

View File

@@ -0,0 +1,41 @@
# Running Migrator CLI
- Generate a new migration file
```sh
cargo run -- generate MIGRATION_NAME
```
- Apply all pending migrations
```sh
cargo run
```
```sh
cargo run -- up
```
- Apply first 10 pending migrations
```sh
cargo run -- up -n 10
```
- Rollback last applied migrations
```sh
cargo run -- down
```
- Rollback last 10 applied migrations
```sh
cargo run -- down -n 10
```
- Drop all tables from the database, then reapply all migrations
```sh
cargo run -- fresh
```
- Rollback all applied migrations, then reapply all migrations
```sh
cargo run -- refresh
```
- Rollback all applied migrations
```sh
cargo run -- reset
```
- Check the status of all migrations
```sh
cargo run -- status
```

View File

@@ -0,0 +1,30 @@
pub use sea_orm_migration::prelude::*;
mod m20250214_000001_create_accounts;
mod m20250214_000002_create_settings_and_exchange_rates;
mod m20250214_000003_create_goals_and_tags;
mod m20250214_000004_create_scheduled_transactions;
mod m20250214_000005_create_transactions;
mod m20250214_000006_create_reconciliations_and_transfers;
mod m20250214_000007_create_transaction_tags;
mod m20250214_000008_create_goal_rules_and_progress;
mod m20250214_000009_create_scheduled_instances;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![
Box::new(m20250214_000001_create_accounts::Migration),
Box::new(m20250214_000002_create_settings_and_exchange_rates::Migration),
Box::new(m20250214_000003_create_goals_and_tags::Migration),
Box::new(m20250214_000004_create_scheduled_transactions::Migration),
Box::new(m20250214_000005_create_transactions::Migration),
Box::new(m20250214_000006_create_reconciliations_and_transfers::Migration),
Box::new(m20250214_000007_create_transaction_tags::Migration),
Box::new(m20250214_000008_create_goal_rules_and_progress::Migration),
Box::new(m20250214_000009_create_scheduled_instances::Migration),
]
}
}

View File

@@ -0,0 +1,65 @@
use sea_orm_migration::{prelude::*, schema::*};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(Accounts::Table)
.if_not_exists()
.col(string(Accounts::Id).primary_key())
.col(string(Accounts::Name))
.col(string(Accounts::AccountType))
.col(string(Accounts::Currency))
.col(string(Accounts::InitialBalance))
.col(string(Accounts::CurrentBalance))
.col(string_null(Accounts::Color))
.col(string_null(Accounts::Icon))
.col(integer(Accounts::SortOrder))
.col(boolean(Accounts::IsActive))
.col(boolean(Accounts::IsArchived))
.col(boolean(Accounts::IncludeInNetWorth))
.col(boolean(Accounts::ShowInCombinedView))
.col(date_time(Accounts::CreatedAt))
.col(date_time(Accounts::UpdatedAt))
.col(integer(Accounts::Version))
.col(string_null(Accounts::DeviceId))
.col(boolean(Accounts::IsDeleted))
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Accounts::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum Accounts {
Table,
Id,
Name,
AccountType,
Currency,
InitialBalance,
CurrentBalance,
Color,
Icon,
SortOrder,
IsActive,
IsArchived,
IncludeInNetWorth,
ShowInCombinedView,
CreatedAt,
UpdatedAt,
Version,
DeviceId,
IsDeleted,
}

View File

@@ -0,0 +1,72 @@
use sea_orm_migration::{prelude::*, schema::*};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// Create settings table
manager
.create_table(
Table::create()
.table(Settings::Table)
.if_not_exists()
.col(string(Settings::Key).primary_key())
.col(string_null(Settings::Value))
.col(date_time(Settings::UpdatedAt))
.to_owned(),
)
.await?;
// Create exchange_rates table
manager
.create_table(
Table::create()
.table(ExchangeRates::Table)
.if_not_exists()
.col(string(ExchangeRates::FromCurrency))
.col(string(ExchangeRates::ToCurrency))
.col(string(ExchangeRates::Date))
.col(string(ExchangeRates::Rate))
.col(string_null(ExchangeRates::Source))
.col(date_time_null(ExchangeRates::FetchedAt))
.primary_key(
Index::create()
.col(ExchangeRates::FromCurrency)
.col(ExchangeRates::ToCurrency)
.col(ExchangeRates::Date),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(ExchangeRates::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(Settings::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum Settings {
Table,
Key,
Value,
UpdatedAt,
}
#[derive(DeriveIden)]
enum ExchangeRates {
Table,
FromCurrency,
ToCurrency,
Date,
Rate,
Source,
FetchedAt,
}

View File

@@ -0,0 +1,132 @@
use sea_orm_migration::{prelude::*, schema::*};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// Create goals table
manager
.create_table(
Table::create()
.table(Goals::Table)
.if_not_exists()
.col(string(Goals::Id).primary_key())
.col(string(Goals::Name))
.col(string_null(Goals::Description))
.col(string(Goals::TargetAmount))
.col(string(Goals::CurrentAmount))
.col(string(Goals::Currency))
.col(string(Goals::GoalType))
.col(string_null(Goals::TargetDate))
.col(boolean(Goals::IsRecurring))
.col(string_null(Goals::RecurrencePeriod))
.col(string_null(Goals::LinkedAccountId))
.col(string_null(Goals::Color))
.col(string_null(Goals::Icon))
.col(boolean(Goals::IsActive))
.col(boolean(Goals::IsAchieved))
.col(date_time_null(Goals::AchievedAt))
.col(string_null(Goals::LastResetDate))
.col(date_time(Goals::CreatedAt))
.col(date_time(Goals::UpdatedAt))
.col(integer(Goals::Version))
.col(string_null(Goals::DeviceId))
.col(boolean(Goals::IsDeleted))
.foreign_key(
ForeignKey::create()
.name("fk_goals_account")
.from(Goals::Table, Goals::LinkedAccountId)
.to(Accounts::Table, Accounts::Id)
.on_delete(ForeignKeyAction::SetNull)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
// Create tags table
manager
.create_table(
Table::create()
.table(Tags::Table)
.if_not_exists()
.col(string(Tags::Id).primary_key())
.col(string(Tags::Name))
.col(string(Tags::Color))
.col(string_null(Tags::Icon))
.col(string_null(Tags::BudgetAmount))
.col(string_null(Tags::BudgetPeriod))
.col(boolean(Tags::IsSystem))
.col(integer(Tags::SortOrder))
.col(date_time(Tags::CreatedAt))
.col(date_time(Tags::UpdatedAt))
.col(integer(Tags::Version))
.col(string_null(Tags::DeviceId))
.col(boolean(Tags::IsDeleted))
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Tags::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(Goals::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum Goals {
Table,
Id,
Name,
Description,
TargetAmount,
CurrentAmount,
Currency,
GoalType,
TargetDate,
IsRecurring,
RecurrencePeriod,
LinkedAccountId,
Color,
Icon,
IsActive,
IsAchieved,
AchievedAt,
LastResetDate,
CreatedAt,
UpdatedAt,
Version,
DeviceId,
IsDeleted,
}
#[derive(DeriveIden)]
enum Accounts {
Table,
Id,
}
#[derive(DeriveIden)]
enum Tags {
Table,
Id,
Name,
Color,
Icon,
BudgetAmount,
BudgetPeriod,
IsSystem,
SortOrder,
CreatedAt,
UpdatedAt,
Version,
DeviceId,
IsDeleted,
}

View File

@@ -0,0 +1,103 @@
use sea_orm_migration::{prelude::*, schema::*};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(ScheduledTransactions::Table)
.if_not_exists()
.col(string(ScheduledTransactions::Id).primary_key())
.col(string(ScheduledTransactions::AccountId))
.col(string(ScheduledTransactions::ScheduleType))
.col(integer(ScheduledTransactions::Frequency))
.col(string_null(ScheduledTransactions::DaysOfWeek))
.col(integer_null(ScheduledTransactions::DayOfMonth))
.col(integer_null(ScheduledTransactions::MonthOfYear))
.col(string(ScheduledTransactions::ExecutionTime))
.col(string_null(ScheduledTransactions::Timezone))
.col(string(ScheduledTransactions::StartDate))
.col(string_null(ScheduledTransactions::EndDate))
.col(integer_null(ScheduledTransactions::OccurrenceCount))
.col(integer(ScheduledTransactions::CurrentOccurrence))
.col(string(ScheduledTransactions::TransactionType))
.col(string(ScheduledTransactions::GrossAmount))
.col(string(ScheduledTransactions::TaxAmount))
.col(string(ScheduledTransactions::NetAmount))
.col(string(ScheduledTransactions::Currency))
.col(string_null(ScheduledTransactions::Description))
.col(string_null(ScheduledTransactions::Merchant))
.col(string_null(ScheduledTransactions::Notes))
.col(string_null(ScheduledTransactions::TagIds))
.col(boolean(ScheduledTransactions::IsActive))
.col(string_null(ScheduledTransactions::LastGeneratedDate))
.col(date_time_null(ScheduledTransactions::NextExecutionDatetime))
.col(date_time(ScheduledTransactions::CreatedAt))
.col(date_time(ScheduledTransactions::UpdatedAt))
.col(integer(ScheduledTransactions::Version))
.col(string_null(ScheduledTransactions::DeviceId))
.col(boolean(ScheduledTransactions::IsDeleted))
.foreign_key(
ForeignKey::create()
.name("fk_scheduled_transactions_account")
.from(ScheduledTransactions::Table, ScheduledTransactions::AccountId)
.to(Accounts::Table, Accounts::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(ScheduledTransactions::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum ScheduledTransactions {
Table,
Id,
AccountId,
ScheduleType,
Frequency,
DaysOfWeek,
DayOfMonth,
MonthOfYear,
ExecutionTime,
Timezone,
StartDate,
EndDate,
OccurrenceCount,
CurrentOccurrence,
TransactionType,
GrossAmount,
TaxAmount,
NetAmount,
Currency,
Description,
Merchant,
Notes,
TagIds,
IsActive,
LastGeneratedDate,
NextExecutionDatetime,
CreatedAt,
UpdatedAt,
Version,
DeviceId,
IsDeleted,
}
#[derive(DeriveIden)]
enum Accounts {
Table,
Id,
}

View File

@@ -0,0 +1,109 @@
use sea_orm_migration::{prelude::*, schema::*};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(Transactions::Table)
.if_not_exists()
.col(string(Transactions::Id).primary_key())
.col(string(Transactions::AccountId))
.col(string(Transactions::TransactionType))
.col(string(Transactions::GrossAmount))
.col(string(Transactions::TaxAmount))
.col(string(Transactions::NetAmount))
.col(string_null(Transactions::TaxRate))
.col(string(Transactions::Currency))
.col(string(Transactions::Description))
.col(string_null(Transactions::Merchant))
.col(string_null(Transactions::Notes))
.col(string_null(Transactions::ReceiptPaths))
.col(string_null(Transactions::ReceiptOcrData))
.col(string_null(Transactions::TransferId))
.col(string_null(Transactions::RelatedTransactionId))
.col(string_null(Transactions::ScheduleId))
.col(boolean(Transactions::IsScheduledInstance))
.col(boolean(Transactions::IsAutoInserted))
.col(boolean(Transactions::NeedsReview))
.col(string(Transactions::TransactionDate))
.col(date_time(Transactions::CreatedAt))
.col(date_time(Transactions::UpdatedAt))
.col(integer(Transactions::Version))
.col(string_null(Transactions::DeviceId))
.col(boolean(Transactions::IsDeleted))
.col(string(Transactions::SyncStatus))
.foreign_key(
ForeignKey::create()
.name("fk_transactions_account")
.from(Transactions::Table, Transactions::AccountId)
.to(Accounts::Table, Accounts::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.name("fk_transactions_schedule")
.from(Transactions::Table, Transactions::ScheduleId)
.to(ScheduledTransactions::Table, ScheduledTransactions::Id)
.on_delete(ForeignKeyAction::SetNull)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Transactions::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum Transactions {
Table,
Id,
AccountId,
TransactionType,
GrossAmount,
TaxAmount,
NetAmount,
TaxRate,
Currency,
Description,
Merchant,
Notes,
ReceiptPaths,
ReceiptOcrData,
TransferId,
RelatedTransactionId,
ScheduleId,
IsScheduledInstance,
IsAutoInserted,
NeedsReview,
TransactionDate,
CreatedAt,
UpdatedAt,
Version,
DeviceId,
IsDeleted,
SyncStatus,
}
#[derive(DeriveIden)]
enum Accounts {
Table,
Id,
}
#[derive(DeriveIden)]
enum ScheduledTransactions {
Table,
Id,
}

View File

@@ -0,0 +1,118 @@
use sea_orm_migration::{prelude::*, schema::*};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// Create reconciliations table
manager
.create_table(
Table::create()
.table(Reconciliations::Table)
.if_not_exists()
.col(string(Reconciliations::Id).primary_key())
.col(string(Reconciliations::AccountId))
.col(string(Reconciliations::StatementDate))
.col(string(Reconciliations::StatementBalance))
.col(string(Reconciliations::AppBalance))
.col(string(Reconciliations::Difference))
.col(string(Reconciliations::Status))
.col(string_null(Reconciliations::Notes))
.col(date_time(Reconciliations::CreatedAt))
.col(date_time_null(Reconciliations::ResolvedAt))
.col(integer(Reconciliations::Version))
.col(string_null(Reconciliations::DeviceId))
.foreign_key(
ForeignKey::create()
.name("fk_reconciliations_account")
.from(Reconciliations::Table, Reconciliations::AccountId)
.to(Accounts::Table, Accounts::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
// Create transfers table
manager
.create_table(
Table::create()
.table(Transfers::Table)
.if_not_exists()
.col(string(Transfers::Id).primary_key())
.col(string(Transfers::FromAccountId))
.col(string(Transfers::ToAccountId))
.col(string_null(Transfers::FromTransactionId))
.col(string_null(Transfers::ToTransactionId))
.col(string(Transfers::FromAmount))
.col(string(Transfers::ToAmount))
.col(string_null(Transfers::ExchangeRate))
.col(string_null(Transfers::ExchangeRateSource))
.col(string(Transfers::Fees))
.col(string_null(Transfers::Description))
.col(string(Transfers::TransferDate))
.col(date_time(Transfers::CreatedAt))
.col(integer(Transfers::Version))
.col(string_null(Transfers::DeviceId))
.col(boolean(Transfers::IsDeleted))
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Transfers::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(Reconciliations::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum Reconciliations {
Table,
Id,
AccountId,
StatementDate,
StatementBalance,
AppBalance,
Difference,
Status,
Notes,
CreatedAt,
ResolvedAt,
Version,
DeviceId,
}
#[derive(DeriveIden)]
enum Transfers {
Table,
Id,
FromAccountId,
ToAccountId,
FromTransactionId,
ToTransactionId,
FromAmount,
ToAmount,
ExchangeRate,
ExchangeRateSource,
Fees,
Description,
TransferDate,
CreatedAt,
Version,
DeviceId,
IsDeleted,
}
#[derive(DeriveIden)]
enum Accounts {
Table,
Id,
}

View File

@@ -0,0 +1,66 @@
use sea_orm_migration::{prelude::*, schema::*};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(TransactionTags::Table)
.if_not_exists()
.col(string(TransactionTags::TransactionId))
.col(string(TransactionTags::TagId))
.primary_key(
Index::create()
.col(TransactionTags::TransactionId)
.col(TransactionTags::TagId),
)
.foreign_key(
ForeignKey::create()
.name("fk_transaction_tags_transaction")
.from(TransactionTags::Table, TransactionTags::TransactionId)
.to(Transactions::Table, Transactions::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.name("fk_transaction_tags_tag")
.from(TransactionTags::Table, TransactionTags::TagId)
.to(Tags::Table, Tags::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(TransactionTags::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum TransactionTags {
Table,
TransactionId,
TagId,
}
#[derive(DeriveIden)]
enum Transactions {
Table,
Id,
}
#[derive(DeriveIden)]
enum Tags {
Table,
Id,
}

View File

@@ -0,0 +1,124 @@
use sea_orm_migration::{prelude::*, schema::*};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// Create goal_rules table
manager
.create_table(
Table::create()
.table(GoalRules::Table)
.if_not_exists()
.col(string(GoalRules::Id).primary_key())
.col(string(GoalRules::GoalId))
.col(string(GoalRules::TagIds))
.col(string(GoalRules::ContributionType))
.col(string_null(GoalRules::Percentage))
.col(string_null(GoalRules::FixedAmount))
.col(string_null(GoalRules::MaxContributionPerTransaction))
.col(string_null(GoalRules::MonthlyCap))
.col(boolean(GoalRules::IsActive))
.col(date_time(GoalRules::CreatedAt))
.col(date_time(GoalRules::UpdatedAt))
.col(integer(GoalRules::Version))
.col(string_null(GoalRules::DeviceId))
.col(boolean(GoalRules::IsDeleted))
.foreign_key(
ForeignKey::create()
.name("fk_goal_rules_goal")
.from(GoalRules::Table, GoalRules::GoalId)
.to(Goals::Table, Goals::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
// Create goal_progress table
manager
.create_table(
Table::create()
.table(GoalProgress::Table)
.if_not_exists()
.col(string(GoalProgress::Id).primary_key())
.col(string(GoalProgress::GoalId))
.col(string(GoalProgress::Amount))
.col(string_null(GoalProgress::TransactionId))
.col(string_null(GoalProgress::Notes))
.col(date_time(GoalProgress::RecordedAt))
.foreign_key(
ForeignKey::create()
.name("fk_goal_progress_goal")
.from(GoalProgress::Table, GoalProgress::GoalId)
.to(Goals::Table, Goals::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.name("fk_goal_progress_transaction")
.from(GoalProgress::Table, GoalProgress::TransactionId)
.to(Transactions::Table, Transactions::Id)
.on_delete(ForeignKeyAction::SetNull)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(GoalProgress::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(GoalRules::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum GoalRules {
Table,
Id,
GoalId,
TagIds,
ContributionType,
Percentage,
FixedAmount,
MaxContributionPerTransaction,
MonthlyCap,
IsActive,
CreatedAt,
UpdatedAt,
Version,
DeviceId,
IsDeleted,
}
#[derive(DeriveIden)]
enum GoalProgress {
Table,
Id,
GoalId,
Amount,
TransactionId,
Notes,
RecordedAt,
}
#[derive(DeriveIden)]
enum Goals {
Table,
Id,
}
#[derive(DeriveIden)]
enum Transactions {
Table,
Id,
}

View File

@@ -0,0 +1,75 @@
use sea_orm_migration::{prelude::*, schema::*};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(ScheduledInstances::Table)
.if_not_exists()
.col(string(ScheduledInstances::Id).primary_key())
.col(string(ScheduledInstances::ScheduleId))
.col(string_null(ScheduledInstances::TransactionId))
.col(string(ScheduledInstances::DueDate))
.col(boolean(ScheduledInstances::IsGenerated))
.col(boolean(ScheduledInstances::IsSkipped))
.col(date_time_null(ScheduledInstances::GeneratedAt))
.col(boolean(ScheduledInstances::Notified))
.col(date_time(ScheduledInstances::CreatedAt))
.foreign_key(
ForeignKey::create()
.name("fk_scheduled_instances_schedule")
.from(ScheduledInstances::Table, ScheduledInstances::ScheduleId)
.to(ScheduledTransactions::Table, ScheduledTransactions::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.name("fk_scheduled_instances_transaction")
.from(ScheduledInstances::Table, ScheduledInstances::TransactionId)
.to(Transactions::Table, Transactions::Id)
.on_delete(ForeignKeyAction::SetNull)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(ScheduledInstances::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum ScheduledInstances {
Table,
Id,
ScheduleId,
TransactionId,
DueDate,
IsGenerated,
IsSkipped,
GeneratedAt,
Notified,
CreatedAt,
}
#[derive(DeriveIden)]
enum ScheduledTransactions {
Table,
Id,
}
#[derive(DeriveIden)]
enum Transactions {
Table,
Id,
}

View File

@@ -0,0 +1,6 @@
use sea_orm_migration::prelude::*;
#[tokio::main]
async fn main() {
cli::run_cli(migration::Migrator).await;
}

57
justfile Normal file
View File

@@ -0,0 +1,57 @@
default:
@just --list
act:
act --pull=false
init:
cargo install sea-orm-cli@^2.0.0-rc
db-migrate:
cd crates && \
rm temp.db || true && \
touch temp.db && \
sea-orm-cli migrate -u sqlite://temp.db && \
sea-orm-cli generate entity \
-u sqlite://temp.db \
-o ../src-tauri/src/db/entities \
--with-serde both \
--date-time-crate chrono \
--with-prelude all-allow-unused-imports \
--model-extra-attributes 'allow(dead_code)' \
--enum-extra-attributes 'allow(dead_code)'
rm crates/temp.db
start-vnc:
@echo "Run the manual VNC start script"
bash .devcontainer/manual-start-vnc.sh
stop-vnc:
@echo "Stop x11vnc and novnc proxy"
pkill -f x11vnc || true
pkill -f novnc_proxy || true
dev DISPLAY='1':
# Note: this assumes that the VNC server is running and accessible at 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'
build-ci-image:
docker build -t {{DOCKER_IMAGE_NAME}} -f .github/Dockerfile .
push-ci-image:
docker push {{DOCKER_IMAGE_NAME}}
update-ci-image: build-ci-image push-ci-image
pull-ci-image:
docker pull {{DOCKER_IMAGE_NAME}}

View File

@@ -7,20 +7,22 @@
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri"
"tauri": "tauri",
"lint": "echo 'No linting configured for frontend yet'"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2"
"@tauri-apps/plugin-log": "^2.8.0",
"@tauri-apps/plugin-opener": "^2",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"typescript": "~5.8.3",
"vite": "^7.0.4",
"@tauri-apps/cli": "^2"
"vite": "^7.0.4"
}
}
}

28
pnpm-lock.yaml generated
View File

@@ -11,6 +11,9 @@ importers:
'@tauri-apps/api':
specifier: ^2
version: 2.10.1
'@tauri-apps/plugin-log':
specifier: ^2.8.0
version: 2.8.0
'@tauri-apps/plugin-opener':
specifier: ^2
version: 2.5.3
@@ -334,66 +337,79 @@ packages:
resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.57.1':
resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.57.1':
resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.57.1':
resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.57.1':
resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.57.1':
resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.57.1':
resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.57.1':
resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.57.1':
resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.57.1':
resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.57.1':
resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.57.1':
resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.57.1':
resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.57.1':
resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==}
@@ -451,30 +467,35 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-arm64-musl@2.10.0':
resolution: {integrity: sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-linux-riscv64-gnu@2.10.0':
resolution: {integrity: sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-gnu@2.10.0':
resolution: {integrity: sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-musl@2.10.0':
resolution: {integrity: sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-win32-arm64-msvc@2.10.0':
resolution: {integrity: sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==}
@@ -499,6 +520,9 @@ packages:
engines: {node: '>= 10'}
hasBin: true
'@tauri-apps/plugin-log@2.8.0':
resolution: {integrity: sha512-a+7rOq3MJwpTOLLKbL8d0qGZ85hgHw5pNOWusA9o3cf7cEgtYHiGY/+O8fj8MvywQIGqFv0da2bYQDlrqLE7rw==}
'@tauri-apps/plugin-opener@2.5.3':
resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==}
@@ -1050,6 +1074,10 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.10.0
'@tauri-apps/cli-win32-x64-msvc': 2.10.0
'@tauri-apps/plugin-log@2.8.0':
dependencies:
'@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-opener@2.5.3':
dependencies:
'@tauri-apps/api': 2.10.1

1387
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,23 +3,50 @@ name = "finwise"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
edition = "2024"
rust-version = "1.85.0"
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "finwise_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[[bin]]
name = "finwise"
path = "src/main.rs"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sea-orm = { workspace = true }
chrono = { workspace = true }
migration = { path = "../crates/migration" }
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
[profile.release]
codegen-units = 1
lto = true
opt-level = 3
# panic = "abort"
strip = true

View File

@@ -2,9 +2,12 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"windows": [
"main"
],
"permissions": [
"core:default",
"opener:default"
"opener:default",
"log:default"
]
}
}

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
pub mod accounts;
pub mod exchange_rate;
pub mod settings;
pub mod transactions;

View File

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

View File

@@ -0,0 +1,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
}

View File

@@ -0,0 +1,161 @@
use std::path::PathBuf;
use log::error;
use sea_orm::{
ConnectOptions, ConnectionTrait, Database, DatabaseConnection, DatabaseTransaction, DbBackend,
DbErr, ExecResult, QueryResult, Statement,
};
use tauri::{AppHandle, Manager};
use super::migrations;
const DATABASE_PATH: &str = "finance.db";
pub(super) async fn establish_connection(
app_handle: &AppHandle,
) -> Result<DatabaseConnection, DbErr> {
let app_dir = app_handle.path().app_data_dir().map_err(|err| {
error!("Failed to get app data directory: {}", err);
DbErr::Custom("Failed to get app data directory".to_string())
})?;
// Create directory if it doesn't exist
std::fs::create_dir_all(&app_dir).map_err(|err| {
error!("Failed to create app data directory: {}", err);
DbErr::Custom("Failed to create app data directory".to_string())
})?;
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(opt).await?;
// Enable foreign keys and set pragmas
sea_orm::ConnectionTrait::execute_unprepared(
&db,
"PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;",
)
.await?;
// Run migrations
migrations::run_migrations(&db).await?;
Ok(db)
}
pub(super) fn get_database_path(app_handle: &AppHandle) -> PathBuf {
app_handle
.path()
.app_data_dir()
.map(|dir| dir.join(DATABASE_PATH))
.map_err(|err| {
error!("Failed to get app data directory: {}", err);
DbErr::Custom("Failed to get app data directory".to_string())
})
.unwrap_or_else(|_| PathBuf::from(DATABASE_PATH)) // Fallback to current directory if app data dir is not accessible
}
pub enum ConnectionSource<'a> {
Transaction(&'a DatabaseTransaction),
Connection(&'a DatabaseConnection),
}
#[async_trait::async_trait]
impl ConnectionTrait for &ConnectionSource<'_> {
fn get_database_backend(&self) -> DbBackend {
match self {
ConnectionSource::Transaction(tx) => tx.get_database_backend(),
ConnectionSource::Connection(conn) => conn.get_database_backend(),
}
}
async fn execute_raw(&self, stmt: Statement) -> Result<ExecResult, DbErr> {
match self {
ConnectionSource::Transaction(tx) => tx.execute_raw(stmt).await,
ConnectionSource::Connection(conn) => conn.execute_raw(stmt).await,
}
}
async fn execute_unprepared(&self, sql: &str) -> Result<ExecResult, DbErr> {
match self {
ConnectionSource::Transaction(tx) => tx.execute_unprepared(sql).await,
ConnectionSource::Connection(conn) => conn.execute_unprepared(sql).await,
}
}
async fn query_one_raw(&self, stmt: Statement) -> Result<Option<QueryResult>, DbErr> {
match self {
ConnectionSource::Transaction(tx) => tx.query_one_raw(stmt).await,
ConnectionSource::Connection(conn) => conn.query_one_raw(stmt).await,
}
}
async fn query_all_raw(&self, stmt: Statement) -> Result<Vec<QueryResult>, DbErr> {
match self {
ConnectionSource::Transaction(tx) => tx.query_all_raw(stmt).await,
ConnectionSource::Connection(conn) => conn.query_all_raw(stmt).await,
}
}
fn is_mock_connection(&self) -> bool {
match self {
ConnectionSource::Transaction(tx) => tx.is_mock_connection(),
ConnectionSource::Connection(conn) => conn.is_mock_connection(),
}
}
}
#[async_trait::async_trait]
impl ConnectionTrait for ConnectionSource<'_> {
fn get_database_backend(&self) -> DbBackend {
match self {
ConnectionSource::Transaction(tx) => tx.get_database_backend(),
ConnectionSource::Connection(conn) => conn.get_database_backend(),
}
}
async fn execute_raw(&self, stmt: Statement) -> Result<ExecResult, DbErr> {
match self {
ConnectionSource::Transaction(tx) => tx.execute_raw(stmt).await,
ConnectionSource::Connection(conn) => conn.execute_raw(stmt).await,
}
}
async fn execute_unprepared(&self, sql: &str) -> Result<ExecResult, DbErr> {
match self {
ConnectionSource::Transaction(tx) => tx.execute_unprepared(sql).await,
ConnectionSource::Connection(conn) => conn.execute_unprepared(sql).await,
}
}
async fn query_one_raw(&self, stmt: Statement) -> Result<Option<QueryResult>, DbErr> {
match self {
ConnectionSource::Transaction(tx) => tx.query_one_raw(stmt).await,
ConnectionSource::Connection(conn) => conn.query_one_raw(stmt).await,
}
}
async fn query_all_raw(&self, stmt: Statement) -> Result<Vec<QueryResult>, DbErr> {
match self {
ConnectionSource::Transaction(tx) => tx.query_all_raw(stmt).await,
ConnectionSource::Connection(conn) => conn.query_all_raw(stmt).await,
}
}
fn is_mock_connection(&self) -> bool {
match self {
ConnectionSource::Transaction(tx) => tx.is_mock_connection(),
ConnectionSource::Connection(conn) => conn.is_mock_connection(),
}
}
}

View File

@@ -0,0 +1,67 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "accounts")]
#[allow(dead_code)]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: String,
pub name: String,
pub account_type: String,
pub currency: String,
pub initial_balance: String,
pub current_balance: String,
pub color: Option<String>,
pub icon: Option<String>,
pub sort_order: i64,
pub is_active: bool,
pub is_archived: bool,
pub include_in_net_worth: bool,
pub show_in_combined_view: bool,
pub created_at: DateTime,
pub updated_at: DateTime,
pub version: i64,
pub device_id: Option<String>,
pub is_deleted: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::goals::Entity")]
Goals,
#[sea_orm(has_many = "super::reconciliations::Entity")]
Reconciliations,
#[sea_orm(has_many = "super::scheduled_transactions::Entity")]
ScheduledTransactions,
#[sea_orm(has_many = "super::transactions::Entity")]
Transactions,
}
impl Related<super::goals::Entity> for Entity {
fn to() -> RelationDef {
Relation::Goals.def()
}
}
impl Related<super::reconciliations::Entity> for Entity {
fn to() -> RelationDef {
Relation::Reconciliations.def()
}
}
impl Related<super::scheduled_transactions::Entity> for Entity {
fn to() -> RelationDef {
Relation::ScheduledTransactions.def()
}
}
impl Related<super::transactions::Entity> for Entity {
fn to() -> RelationDef {
Relation::Transactions.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,24 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "exchange_rates")]
#[allow(dead_code)]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub from_currency: String,
#[sea_orm(primary_key, auto_increment = false)]
pub to_currency: String,
#[sea_orm(primary_key, auto_increment = false)]
pub date: String,
pub rate: String,
pub source: Option<String>,
pub fetched_at: Option<DateTime>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,51 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "goal_progress")]
#[allow(dead_code)]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: String,
pub goal_id: String,
pub amount: String,
pub transaction_id: Option<String>,
pub notes: Option<String>,
pub recorded_at: DateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::goals::Entity",
from = "Column::GoalId",
to = "super::goals::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
Goals,
#[sea_orm(
belongs_to = "super::transactions::Entity",
from = "Column::TransactionId",
to = "super::transactions::Column::Id",
on_update = "Cascade",
on_delete = "SetNull"
)]
Transactions,
}
impl Related<super::goals::Entity> for Entity {
fn to() -> RelationDef {
Relation::Goals.def()
}
}
impl Related<super::transactions::Entity> for Entity {
fn to() -> RelationDef {
Relation::Transactions.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,45 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "goal_rules")]
#[allow(dead_code)]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: String,
pub goal_id: String,
pub tag_ids: String,
pub contribution_type: String,
pub percentage: Option<String>,
pub fixed_amount: Option<String>,
pub max_contribution_per_transaction: Option<String>,
pub monthly_cap: Option<String>,
pub is_active: bool,
pub created_at: DateTime,
pub updated_at: DateTime,
pub version: i64,
pub device_id: Option<String>,
pub is_deleted: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::goals::Entity",
from = "Column::GoalId",
to = "super::goals::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
Goals,
}
impl Related<super::goals::Entity> for Entity {
fn to() -> RelationDef {
Relation::Goals.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,69 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "goals")]
#[allow(dead_code)]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: String,
pub name: String,
pub description: Option<String>,
pub target_amount: String,
pub current_amount: String,
pub currency: String,
pub goal_type: String,
pub target_date: Option<String>,
pub is_recurring: bool,
pub recurrence_period: Option<String>,
pub linked_account_id: Option<String>,
pub color: Option<String>,
pub icon: Option<String>,
pub is_active: bool,
pub is_achieved: bool,
pub achieved_at: Option<DateTime>,
pub last_reset_date: Option<String>,
pub created_at: DateTime,
pub updated_at: DateTime,
pub version: i64,
pub device_id: Option<String>,
pub is_deleted: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::accounts::Entity",
from = "Column::LinkedAccountId",
to = "super::accounts::Column::Id",
on_update = "Cascade",
on_delete = "SetNull"
)]
Accounts,
#[sea_orm(has_many = "super::goal_progress::Entity")]
GoalProgress,
#[sea_orm(has_many = "super::goal_rules::Entity")]
GoalRules,
}
impl Related<super::accounts::Entity> for Entity {
fn to() -> RelationDef {
Relation::Accounts.def()
}
}
impl Related<super::goal_progress::Entity> for Entity {
fn to() -> RelationDef {
Relation::GoalProgress.def()
}
}
impl Related<super::goal_rules::Entity> for Entity {
fn to() -> RelationDef {
Relation::GoalRules.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,17 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
pub mod prelude;
pub mod accounts;
pub mod exchange_rates;
pub mod goal_progress;
pub mod goal_rules;
pub mod goals;
pub mod reconciliations;
pub mod scheduled_instances;
pub mod scheduled_transactions;
pub mod settings;
pub mod tags;
pub mod transaction_tags;
pub mod transactions;
pub mod transfers;

View File

@@ -0,0 +1,17 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
#![allow(unused_imports)]
pub use super::accounts::Entity as Accounts;
pub use super::exchange_rates::Entity as ExchangeRates;
pub use super::goal_progress::Entity as GoalProgress;
pub use super::goal_rules::Entity as GoalRules;
pub use super::goals::Entity as Goals;
pub use super::reconciliations::Entity as Reconciliations;
pub use super::scheduled_instances::Entity as ScheduledInstances;
pub use super::scheduled_transactions::Entity as ScheduledTransactions;
pub use super::settings::Entity as Settings;
pub use super::tags::Entity as Tags;
pub use super::transaction_tags::Entity as TransactionTags;
pub use super::transactions::Entity as Transactions;
pub use super::transfers::Entity as Transfers;

View File

@@ -0,0 +1,43 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "reconciliations")]
#[allow(dead_code)]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: String,
pub account_id: String,
pub statement_date: String,
pub statement_balance: String,
pub app_balance: String,
pub difference: String,
pub status: String,
pub notes: Option<String>,
pub created_at: DateTime,
pub resolved_at: Option<DateTime>,
pub version: i64,
pub device_id: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::accounts::Entity",
from = "Column::AccountId",
to = "super::accounts::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
Accounts,
}
impl Related<super::accounts::Entity> for Entity {
fn to() -> RelationDef {
Relation::Accounts.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,54 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "scheduled_instances")]
#[allow(dead_code)]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: String,
pub schedule_id: String,
pub transaction_id: Option<String>,
pub due_date: String,
pub is_generated: bool,
pub is_skipped: bool,
pub generated_at: Option<DateTime>,
pub notified: bool,
pub created_at: DateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::scheduled_transactions::Entity",
from = "Column::ScheduleId",
to = "super::scheduled_transactions::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
ScheduledTransactions,
#[sea_orm(
belongs_to = "super::transactions::Entity",
from = "Column::TransactionId",
to = "super::transactions::Column::Id",
on_update = "Cascade",
on_delete = "SetNull"
)]
Transactions,
}
impl Related<super::scheduled_transactions::Entity> for Entity {
fn to() -> RelationDef {
Relation::ScheduledTransactions.def()
}
}
impl Related<super::transactions::Entity> for Entity {
fn to() -> RelationDef {
Relation::Transactions.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,77 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "scheduled_transactions")]
#[allow(dead_code)]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: String,
pub account_id: String,
pub schedule_type: String,
pub frequency: i64,
pub days_of_week: Option<String>,
pub day_of_month: Option<i64>,
pub month_of_year: Option<i64>,
pub execution_time: String,
pub timezone: Option<String>,
pub start_date: String,
pub end_date: Option<String>,
pub occurrence_count: Option<i64>,
pub current_occurrence: i64,
pub transaction_type: String,
pub gross_amount: String,
pub tax_amount: String,
pub net_amount: String,
pub currency: String,
pub description: Option<String>,
pub merchant: Option<String>,
pub notes: Option<String>,
pub tag_ids: Option<String>,
pub is_active: bool,
pub last_generated_date: Option<String>,
pub next_execution_datetime: Option<DateTime>,
pub created_at: DateTime,
pub updated_at: DateTime,
pub version: i64,
pub device_id: Option<String>,
pub is_deleted: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::accounts::Entity",
from = "Column::AccountId",
to = "super::accounts::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
Accounts,
#[sea_orm(has_many = "super::scheduled_instances::Entity")]
ScheduledInstances,
#[sea_orm(has_many = "super::transactions::Entity")]
Transactions,
}
impl Related<super::accounts::Entity> for Entity {
fn to() -> RelationDef {
Relation::Accounts.def()
}
}
impl Related<super::scheduled_instances::Entity> for Entity {
fn to() -> RelationDef {
Relation::ScheduledInstances.def()
}
}
impl Related<super::transactions::Entity> for Entity {
fn to() -> RelationDef {
Relation::Transactions.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,19 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "settings")]
#[allow(dead_code)]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub key: String,
pub value: Option<String>,
pub updated_at: DateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,47 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "tags")]
#[allow(dead_code)]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: String,
pub name: String,
pub color: String,
pub icon: Option<String>,
pub budget_amount: Option<String>,
pub budget_period: Option<String>,
pub is_system: bool,
pub sort_order: i64,
pub created_at: DateTime,
pub updated_at: DateTime,
pub version: i64,
pub device_id: Option<String>,
pub is_deleted: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::transaction_tags::Entity")]
TransactionTags,
}
impl Related<super::transaction_tags::Entity> for Entity {
fn to() -> RelationDef {
Relation::TransactionTags.def()
}
}
impl Related<super::transactions::Entity> for Entity {
fn to() -> RelationDef {
super::transaction_tags::Relation::Transactions.def()
}
fn via() -> Option<RelationDef> {
Some(super::transaction_tags::Relation::Tags.def().rev())
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,48 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "transaction_tags")]
#[allow(dead_code)]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub transaction_id: String,
#[sea_orm(primary_key, auto_increment = false)]
pub tag_id: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::tags::Entity",
from = "Column::TagId",
to = "super::tags::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
Tags,
#[sea_orm(
belongs_to = "super::transactions::Entity",
from = "Column::TransactionId",
to = "super::transactions::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
Transactions,
}
impl Related<super::tags::Entity> for Entity {
fn to() -> RelationDef {
Relation::Tags.def()
}
}
impl Related<super::transactions::Entity> for Entity {
fn to() -> RelationDef {
Relation::Transactions.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,104 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "transactions")]
#[allow(dead_code)]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: String,
pub account_id: String,
pub transaction_type: String,
pub gross_amount: String,
pub tax_amount: String,
pub net_amount: String,
pub tax_rate: Option<String>,
pub currency: String,
pub description: String,
pub merchant: Option<String>,
pub notes: Option<String>,
pub receipt_paths: Option<String>,
pub receipt_ocr_data: Option<String>,
pub transfer_id: Option<String>,
pub related_transaction_id: Option<String>,
pub schedule_id: Option<String>,
pub is_scheduled_instance: bool,
pub is_auto_inserted: bool,
pub needs_review: bool,
pub transaction_date: String,
pub created_at: DateTime,
pub updated_at: DateTime,
pub version: i64,
pub device_id: Option<String>,
pub is_deleted: bool,
pub sync_status: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::accounts::Entity",
from = "Column::AccountId",
to = "super::accounts::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
Accounts,
#[sea_orm(has_many = "super::goal_progress::Entity")]
GoalProgress,
#[sea_orm(has_many = "super::scheduled_instances::Entity")]
ScheduledInstances,
#[sea_orm(
belongs_to = "super::scheduled_transactions::Entity",
from = "Column::ScheduleId",
to = "super::scheduled_transactions::Column::Id",
on_update = "Cascade",
on_delete = "SetNull"
)]
ScheduledTransactions,
#[sea_orm(has_many = "super::transaction_tags::Entity")]
TransactionTags,
}
impl Related<super::accounts::Entity> for Entity {
fn to() -> RelationDef {
Relation::Accounts.def()
}
}
impl Related<super::goal_progress::Entity> for Entity {
fn to() -> RelationDef {
Relation::GoalProgress.def()
}
}
impl Related<super::scheduled_instances::Entity> for Entity {
fn to() -> RelationDef {
Relation::ScheduledInstances.def()
}
}
impl Related<super::scheduled_transactions::Entity> for Entity {
fn to() -> RelationDef {
Relation::ScheduledTransactions.def()
}
}
impl Related<super::transaction_tags::Entity> for Entity {
fn to() -> RelationDef {
Relation::TransactionTags.def()
}
}
impl Related<super::tags::Entity> for Entity {
fn to() -> RelationDef {
super::transaction_tags::Relation::Tags.def()
}
fn via() -> Option<RelationDef> {
Some(super::transaction_tags::Relation::Transactions.def().rev())
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,32 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "transfers")]
#[allow(dead_code)]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: String,
pub from_account_id: String,
pub to_account_id: String,
pub from_transaction_id: Option<String>,
pub to_transaction_id: Option<String>,
pub from_amount: String,
pub to_amount: String,
pub exchange_rate: Option<String>,
pub exchange_rate_source: Option<String>,
pub fees: String,
pub description: Option<String>,
pub transfer_date: String,
pub created_at: DateTime,
pub version: i64,
pub device_id: Option<String>,
pub is_deleted: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,7 @@
use migration::{Migrator, MigratorTrait};
use sea_orm::DatabaseConnection;
pub(super) async fn run_migrations(conn: &DatabaseConnection) -> Result<(), sea_orm::DbErr> {
Migrator::up(conn, None).await?;
Ok(())
}

5
src-tauri/src/db/mod.rs Normal file
View File

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

View File

@@ -0,0 +1,26 @@
use sea_orm::{DatabaseConnection, DbErr};
use super::connection;
pub struct DbService {
connection: DatabaseConnection,
}
impl DbService {
pub async fn new(app_handle: &tauri::AppHandle) -> Result<Self, DbErr> {
let connection = connection::establish_connection(app_handle).await?;
Ok(Self { connection })
}
pub fn get_connection(&self) -> &DatabaseConnection {
&self.connection
}
pub fn get_connection_mut(&mut self) -> &mut DatabaseConnection {
&mut self.connection
}
pub async fn run_migrations(&self) -> Result<(), DbErr> {
crate::db::migrations::run_migrations(&self.connection).await
}
}

View File

@@ -0,0 +1,95 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("Database error: {0}")]
Database(#[from] sea_orm::DbErr),
#[error("Validation error: {0}")]
Validation(String),
#[error("Not found: {0}")]
NotFound(String),
#[error("Invalid amount: {0}")]
InvalidAmount(String),
#[error("Invalid data: {0}")]
InvalidData(String),
#[error("Currency mismatch: expected {expected}, got {actual}")]
CurrencyMismatch { expected: String, actual: String },
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("Internal error: {0}")]
Internal(String),
#[error("{0}")]
Other(String),
}
#[derive(serde::Serialize)]
#[serde(tag = "kind", content = "message")]
#[serde(rename_all = "camelCase")]
enum ErrorKind {
Database(String),
Validation(String),
NotFound(String),
InvalidAmount(String),
InvalidData(String),
CurrencyMismatch { expected: String, actual: String },
Io(String),
Serialization(String),
Internal(String),
Other(String),
}
impl serde::Serialize for AppError {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::ser::Serializer,
{
let error_message = self.to_string();
let error_kind: ErrorKind = match self {
Self::Io(_) => ErrorKind::Io(error_message),
Self::Database(_) => ErrorKind::Database(error_message),
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(),
},
Self::Serialization(_) => ErrorKind::Serialization(error_message),
Self::Internal(_) => ErrorKind::Internal(error_message),
Self::Other(_) => ErrorKind::Other(error_message),
};
error_kind.serialize(serializer)
}
}
impl From<AppError> for String {
fn from(error: AppError) -> Self {
error.to_string()
}
}
impl From<rust_decimal::Error> for AppError {
fn from(error: rust_decimal::Error) -> Self {
AppError::InvalidAmount(error.to_string())
}
}
impl From<chrono::ParseError> for AppError {
fn from(error: chrono::ParseError) -> Self {
AppError::Validation(format!("Date parsing error: {}", error))
}
}
pub type CommandResult<T> = std::result::Result<T, AppError>;

View File

@@ -0,0 +1,3 @@
pub mod app_error;
pub use app_error::{AppError, CommandResult};

View File

@@ -1,14 +1,98 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
#![forbid(unsafe_code)]
#![forbid(clippy::unwrap_used)]
#![deny(clippy::expect_used)]
#![forbid(clippy::panic)]
use tauri::Manager;
mod commands;
mod db;
mod errors;
mod services;
mod state;
use state::AppState;
/// Initialize the database connection and app state
async fn setup_app(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
// Get the app handle
let app_handle = app.handle();
// 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, 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);
Ok(())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let logger = tauri_plugin_log::Builder::new()
.clear_targets()
.target(tauri_plugin_log::Target::new(
tauri_plugin_log::TargetKind::Stdout,
))
.target(tauri_plugin_log::Target::new(
tauri_plugin_log::TargetKind::Webview,
))
.level(log::LevelFilter::Info)
.timezone_strategy(tauri_plugin_log::TimezoneStrategy::UseLocal)
.build();
#[expect(clippy::expect_used)]
tauri::Builder::default()
.plugin(logger)
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![greet])
.setup(|app| {
// Run async setup
tauri::async_runtime::block_on(async {
if let Err(e) = setup_app(app).await {
eprintln!("Failed to setup app: {}", e);
// In production, you might want to show an error dialog
}
});
Ok(())
})
.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");
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +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 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,
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,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,
}

View File

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

View File

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

63
src-tauri/src/state.rs Normal file
View File

@@ -0,0 +1,63 @@
use std::sync::Arc;
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, 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(())
}
}

View File

@@ -22,6 +22,14 @@
}
},
"bundle": {
"android": {
"autoIncrementVersionCode": false,
"minSdkVersion": 24
},
"iOS": {
"minimumSystemVersion": "14.0"
},
"category": "Finance",
"active": true,
"targets": "all",
"icon": [
@@ -32,4 +40,4 @@
"icons/icon.ico"
]
}
}
}

View File

@@ -1,6 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { attachConsole } from '@tauri-apps/plugin-log';
attachConsole();
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>

View File

@@ -26,7 +26,16 @@ export default defineConfig(async () => ({
: undefined,
watch: {
// 3. tell Vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
ignored: [
"**/src-tauri/**",
"**/target/**",
"**/.pnpm-store/**",
"**/.vscode/**",
"**/.devcontainer/**",
"**/crates/**",
"**/doc/**",
"**/*.md",
],
},
},
}));