Compare commits
70 Commits
backend
...
d60a573c64
| Author | SHA1 | Date | |
|---|---|---|---|
| d60a573c64 | |||
| e731c45a71 | |||
| bfbb771cbf | |||
| 30eb0b71cc | |||
| 4e4a656c88 | |||
| 3ff421c200 | |||
| 75efe5768a | |||
| 620df5780b | |||
| 6b987181a8 | |||
| bf04d8d2da | |||
| 7ffc3bac00 | |||
| 7448bbd5e0 | |||
| 8013f2ad61 | |||
| a0d5bae160 | |||
| f526d9ab2b | |||
| 5b3a29f615 | |||
| 49291002ac | |||
| 6f3c5ef106 | |||
| d57eeef78f | |||
| a6625fc55c | |||
| 9dc8166225 | |||
| 716223e45c | |||
| 8f18b8692f | |||
| e99feace0e | |||
| 51246ab378 | |||
| 07accb0265 | |||
| 3be04939c9 | |||
| ac66ae16aa | |||
| c280f7ff8b | |||
| acc0668392 | |||
| 88e8640386 | |||
| 15dfcd2e73 | |||
| 4e1d383285 | |||
| 751c50b9ae | |||
| b5bc2b7d1a | |||
| 3f9f713261 | |||
|
|
70e7b00a15 | ||
|
|
763b69aefa | ||
|
|
21c15d45e4 | ||
| 0eb592458b | |||
|
|
0daa77d757 | ||
|
|
de9fc621ec | ||
|
|
31885dd67e | ||
|
|
94cf1d1d68 | ||
|
|
a95e03f3a7 | ||
|
|
a88f2dc355 | ||
|
|
bd6d752fbb | ||
|
|
af97ef24fc | ||
| 9d5c272c60 | |||
|
|
bcc023cc5f | ||
| d1e9e92d00 | |||
|
|
309f574d45 | ||
|
|
032286eef9 | ||
| d6423791d8 | |||
|
|
7aaf438018 | ||
|
|
ca4ffa0a35 | ||
|
|
850a9fa85f | ||
|
|
16a356cc6d | ||
|
|
ba462365bc | ||
|
|
edf3dc439e | ||
|
|
0c04030357 | ||
|
|
7fcf9e701f | ||
|
|
20eebf25b1 | ||
|
|
606959d687 | ||
|
|
aeac78e7f2 | ||
|
|
c19272076f | ||
|
|
83e9d679ae | ||
|
|
692e38e1d2 | ||
|
|
39fcf26ea9 | ||
|
|
e4dc49310e |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
31
.devcontainer/manual-start-vnc.sh
Executable file
31
.devcontainer/manual-start-vnc.sh
Executable 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"
|
||||
27
.devcontainer/novnc/vnc-wrapper.html
Normal file
27
.devcontainer/novnc/vnc-wrapper.html
Normal 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
138
.devcontainer/start-vnc.sh
Executable 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
37
.github/Dockerfile
vendored
Normal 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
66
.github/actions/setup-rust/action.yml
vendored
Normal 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
61
.github/workflows/lint.yml
vendored
Normal 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
30
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test-crates:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: gitea.gwmc.dev/finwise/finwise-ci:latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Rust, checkout and restore caches
|
||||
uses: ./.github/actions/setup-rust
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
cd src-tauri
|
||||
cargo test --all-features
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -25,3 +25,6 @@ dist-ssr
|
||||
|
||||
#pnpm
|
||||
.pnpm-store
|
||||
|
||||
# target
|
||||
target/
|
||||
|
||||
7271
Cargo.lock
generated
Normal file
7271
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
Cargo.toml
Normal file
15
Cargo.toml
Normal 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"
|
||||
18
crates/migration/Cargo.toml
Normal file
18
crates/migration/Cargo.toml
Normal 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 }
|
||||
41
crates/migration/README.md
Normal file
41
crates/migration/README.md
Normal 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
|
||||
```
|
||||
30
crates/migration/src/lib.rs
Normal file
30
crates/migration/src/lib.rs
Normal 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),
|
||||
]
|
||||
}
|
||||
}
|
||||
65
crates/migration/src/m20250214_000001_create_accounts.rs
Normal file
65
crates/migration/src/m20250214_000001_create_accounts.rs
Normal 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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
132
crates/migration/src/m20250214_000003_create_goals_and_tags.rs
Normal file
132
crates/migration/src/m20250214_000003_create_goals_and_tags.rs
Normal 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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
109
crates/migration/src/m20250214_000005_create_transactions.rs
Normal file
109
crates/migration/src/m20250214_000005_create_transactions.rs
Normal 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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
6
crates/migration/src/main.rs
Normal file
6
crates/migration/src/main.rs
Normal 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
57
justfile
Normal 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}}
|
||||
14
package.json
14
package.json
@@ -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
28
pnpm-lock.yaml
generated
@@ -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
1387
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
99
src-tauri/src/commands/accounts.rs
Normal file
99
src-tauri/src/commands/accounts.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::{
|
||||
errors::CommandResult,
|
||||
services::accounts::{
|
||||
service::AccountModel,
|
||||
types::{
|
||||
inputs::{AccountFilter, AccountType, CreateAccountInput, UpdateAccountInput},
|
||||
outputs::AccountBalance,
|
||||
},
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_account(
|
||||
state: tauri::State<'_, AppState>,
|
||||
input: CreateAccountInput,
|
||||
) -> CommandResult<AccountModel> {
|
||||
state.account_service().create_account(input, None).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_accounts(
|
||||
state: tauri::State<'_, AppState>,
|
||||
include_archived: Option<bool>,
|
||||
account_type: Option<String>,
|
||||
limit: Option<u64>,
|
||||
offset: Option<u64>,
|
||||
) -> CommandResult<Vec<AccountModel>> {
|
||||
let filter = AccountFilter {
|
||||
account_type: account_type.and_then(|s| AccountType::from_str(&s).ok()), // Convert string to AccountType enum
|
||||
include_archived,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
state.account_service().get_accounts(filter, None).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_account(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
) -> CommandResult<AccountModel> {
|
||||
state.account_service().get_account(id, None).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_account(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
updates: UpdateAccountInput,
|
||||
) -> CommandResult<AccountModel> {
|
||||
state
|
||||
.account_service()
|
||||
.update_account(id, updates, None)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn archive_account(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
archived: bool,
|
||||
) -> CommandResult<()> {
|
||||
state
|
||||
.account_service()
|
||||
.archive_account(id, archived, None)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_account(state: tauri::State<'_, AppState>, id: String) -> CommandResult<()> {
|
||||
state.account_service().delete_account(id, None).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_account_balance(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
as_of_date: Option<String>,
|
||||
) -> CommandResult<AccountBalance> {
|
||||
state
|
||||
.account_service()
|
||||
.get_balance(id, as_of_date, None)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn recalculate_account_balance(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
) -> CommandResult<String> {
|
||||
let balance = state
|
||||
.account_service()
|
||||
.recalculate_balance(&id, None)
|
||||
.await?;
|
||||
Ok(balance.to_string())
|
||||
}
|
||||
51
src-tauri/src/commands/exchange_rate.rs
Normal file
51
src-tauri/src/commands/exchange_rate.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use crate::errors::CommandResult;
|
||||
use crate::services::exchange_rate::ExchangeRateAdapterInfo;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_exchange_rate(
|
||||
state: tauri::State<'_, AppState>,
|
||||
from_currency: String,
|
||||
to_currency: String,
|
||||
) -> CommandResult<String> {
|
||||
let rate = state
|
||||
.exchange_rate_service()
|
||||
.get_exchange_rate(&from_currency, &to_currency)
|
||||
.await?;
|
||||
Ok(rate.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_supported_currencies(
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> CommandResult<Vec<String>> {
|
||||
Ok(state
|
||||
.exchange_rate_service()
|
||||
.get_supported_currencies()
|
||||
.await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_available_exchange_rate_adapters(
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> CommandResult<Vec<ExchangeRateAdapterInfo>> {
|
||||
Ok(state.exchange_rate_service().get_available_adapters().await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_exchange_rate_adapter(
|
||||
state: tauri::State<'_, AppState>,
|
||||
adapter_name: String,
|
||||
) -> CommandResult<()> {
|
||||
state
|
||||
.exchange_rate_service()
|
||||
.set_adapter(&adapter_name)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_current_exchange_rate_adapter(
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> CommandResult<String> {
|
||||
state.exchange_rate_service().get_current_adapter().await
|
||||
}
|
||||
4
src-tauri/src/commands/mod.rs
Normal file
4
src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod accounts;
|
||||
pub mod exchange_rate;
|
||||
pub mod settings;
|
||||
pub mod transactions;
|
||||
28
src-tauri/src/commands/settings.rs
Normal file
28
src-tauri/src/commands/settings.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
use crate::errors::CommandResult;
|
||||
use crate::services::settings::types::settings::{Settings, UpdateSettingsInput};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_settings(state: tauri::State<'_, AppState>) -> CommandResult<Settings> {
|
||||
state.settings_service().get_settings(None).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_setting(
|
||||
state: tauri::State<'_, AppState>,
|
||||
key: String,
|
||||
value: String,
|
||||
) -> CommandResult<()> {
|
||||
state
|
||||
.settings_service()
|
||||
.update_setting(key, value, None)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_settings(
|
||||
state: tauri::State<'_, AppState>,
|
||||
input: UpdateSettingsInput,
|
||||
) -> CommandResult<()> {
|
||||
state.settings_service().update_settings(input, None).await
|
||||
}
|
||||
66
src-tauri/src/commands/transactions.rs
Normal file
66
src-tauri/src/commands/transactions.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use crate::errors::CommandResult;
|
||||
use crate::services::transactions::types::inputs::{CreateTransactionInput, TransactionFilter, UpdateTransactionInput};
|
||||
use crate::services::transactions::types::outputs::TransactionWithTags;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_transaction(
|
||||
state: tauri::State<'_, AppState>,
|
||||
input: CreateTransactionInput,
|
||||
) -> CommandResult<TransactionWithTags> {
|
||||
state.transaction_service().create_transaction(input, None).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_transactions(
|
||||
state: tauri::State<'_, AppState>,
|
||||
filter: TransactionFilter,
|
||||
) -> CommandResult<Vec<TransactionWithTags>> {
|
||||
state.transaction_service().get_transactions(filter, None).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_transaction(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
) -> CommandResult<TransactionWithTags> {
|
||||
state.transaction_service().get_transaction(id, None).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_transaction(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
updates: UpdateTransactionInput,
|
||||
) -> CommandResult<TransactionWithTags> {
|
||||
state
|
||||
.transaction_service()
|
||||
.update_transaction(id, updates, None)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_transaction(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
) -> CommandResult<()> {
|
||||
state.transaction_service().delete_transaction(id, None).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_transactions_needing_review(
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> CommandResult<Vec<TransactionWithTags>> {
|
||||
state
|
||||
.transaction_service()
|
||||
.get_transactions_needing_review(None)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn confirm_transaction(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
) -> CommandResult<()> {
|
||||
state.transaction_service().confirm_transaction(id, None).await
|
||||
}
|
||||
161
src-tauri/src/db/connection.rs
Normal file
161
src-tauri/src/db/connection.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
67
src-tauri/src/db/entities/accounts.rs
Normal file
67
src-tauri/src/db/entities/accounts.rs
Normal 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 {}
|
||||
24
src-tauri/src/db/entities/exchange_rates.rs
Normal file
24
src-tauri/src/db/entities/exchange_rates.rs
Normal 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 {}
|
||||
51
src-tauri/src/db/entities/goal_progress.rs
Normal file
51
src-tauri/src/db/entities/goal_progress.rs
Normal 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 {}
|
||||
45
src-tauri/src/db/entities/goal_rules.rs
Normal file
45
src-tauri/src/db/entities/goal_rules.rs
Normal 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 {}
|
||||
69
src-tauri/src/db/entities/goals.rs
Normal file
69
src-tauri/src/db/entities/goals.rs
Normal 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 {}
|
||||
17
src-tauri/src/db/entities/mod.rs
Normal file
17
src-tauri/src/db/entities/mod.rs
Normal 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;
|
||||
17
src-tauri/src/db/entities/prelude.rs
Normal file
17
src-tauri/src/db/entities/prelude.rs
Normal 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;
|
||||
43
src-tauri/src/db/entities/reconciliations.rs
Normal file
43
src-tauri/src/db/entities/reconciliations.rs
Normal 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 {}
|
||||
54
src-tauri/src/db/entities/scheduled_instances.rs
Normal file
54
src-tauri/src/db/entities/scheduled_instances.rs
Normal 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 {}
|
||||
77
src-tauri/src/db/entities/scheduled_transactions.rs
Normal file
77
src-tauri/src/db/entities/scheduled_transactions.rs
Normal 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 {}
|
||||
19
src-tauri/src/db/entities/settings.rs
Normal file
19
src-tauri/src/db/entities/settings.rs
Normal 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 {}
|
||||
47
src-tauri/src/db/entities/tags.rs
Normal file
47
src-tauri/src/db/entities/tags.rs
Normal 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 {}
|
||||
48
src-tauri/src/db/entities/transaction_tags.rs
Normal file
48
src-tauri/src/db/entities/transaction_tags.rs
Normal 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 {}
|
||||
104
src-tauri/src/db/entities/transactions.rs
Normal file
104
src-tauri/src/db/entities/transactions.rs
Normal 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 {}
|
||||
32
src-tauri/src/db/entities/transfers.rs
Normal file
32
src-tauri/src/db/entities/transfers.rs
Normal 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 {}
|
||||
7
src-tauri/src/db/migrations.rs
Normal file
7
src-tauri/src/db/migrations.rs
Normal 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
5
src-tauri/src/db/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod entities;
|
||||
pub mod service;
|
||||
|
||||
pub mod connection;
|
||||
mod migrations;
|
||||
26
src-tauri/src/db/service.rs
Normal file
26
src-tauri/src/db/service.rs
Normal 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
|
||||
}
|
||||
}
|
||||
95
src-tauri/src/errors/app_error.rs
Normal file
95
src-tauri/src/errors/app_error.rs
Normal 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>;
|
||||
3
src-tauri/src/errors/mod.rs
Normal file
3
src-tauri/src/errors/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod app_error;
|
||||
|
||||
pub use app_error::{AppError, CommandResult};
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
2
src-tauri/src/services/accounts/mod.rs
Normal file
2
src-tauri/src/services/accounts/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod service;
|
||||
pub mod types;
|
||||
1601
src-tauri/src/services/accounts/service.rs
Normal file
1601
src-tauri/src/services/accounts/service.rs
Normal file
File diff suppressed because it is too large
Load Diff
185
src-tauri/src/services/accounts/types/inputs.rs
Normal file
185
src-tauri/src/services/accounts/types/inputs.rs
Normal file
@@ -0,0 +1,185 @@
|
||||
use lazy_static::lazy_static;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
lazy_static! {
|
||||
static ref HEX_COLOR_PATTERN: regex::Regex =
|
||||
#[expect(clippy::expect_used)]
|
||||
regex::Regex::new(r"^#([0-9A-Fa-f]{3,4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$")
|
||||
.expect("Invalid hex color regex pattern");
|
||||
}
|
||||
|
||||
const CHECKING: &str = "checking";
|
||||
const SAVINGS: &str = "savings";
|
||||
const CREDIT_CARD: &str = "credit_card";
|
||||
const INVESTMENT: &str = "investment";
|
||||
const LOAN: &str = "loan";
|
||||
const CASH: &str = "cash";
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub enum AccountType {
|
||||
#[serde(rename = "checking")]
|
||||
Checking,
|
||||
#[serde(rename = "savings")]
|
||||
Savings,
|
||||
#[serde(rename = "credit_card")]
|
||||
CreditCard,
|
||||
#[serde(rename = "investment")]
|
||||
Investment,
|
||||
#[serde(rename = "loan")]
|
||||
Loan,
|
||||
#[serde(rename = "cash")]
|
||||
Cash,
|
||||
// the provided string will be used as the account type, allowing for custom types without needing to update the enum
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl std::str::FromStr for AccountType {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
CHECKING => Ok(AccountType::Checking),
|
||||
SAVINGS => Ok(AccountType::Savings),
|
||||
CREDIT_CARD => Ok(AccountType::CreditCard),
|
||||
INVESTMENT => Ok(AccountType::Investment),
|
||||
LOAN => Ok(AccountType::Loan),
|
||||
CASH => Ok(AccountType::Cash),
|
||||
other => Ok(AccountType::Other(other.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AccountType> for String {
|
||||
fn from(account_type: AccountType) -> Self {
|
||||
match account_type {
|
||||
AccountType::Other(s) => s,
|
||||
_ => serde_json::to_string(&account_type)
|
||||
.unwrap_or_else(|err| {
|
||||
log::error!("Failed to serialize AccountType: {}", err);
|
||||
"\"unknown\"".to_string()
|
||||
})
|
||||
.replace('"', ""),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateAccountInput {
|
||||
pub name: String,
|
||||
pub account_type: AccountType,
|
||||
pub currency: String,
|
||||
pub initial_balance: String,
|
||||
// hex color code, e.g. #FF0000
|
||||
pub color: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
impl CreateAccountInput {
|
||||
/// Validates create account input
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.name.trim().is_empty() {
|
||||
return Err("Account name is required".to_string());
|
||||
}
|
||||
if self.name.len() > 100 {
|
||||
return Err("Account name cannot exceed 100 characters".to_string());
|
||||
}
|
||||
let currency_upper = self.currency.to_uppercase();
|
||||
if currency_upper.len() != 3 {
|
||||
return Err("Currency code must be 3 characters (ISO 4217)".to_string());
|
||||
}
|
||||
if !currency_upper.chars().all(|c| c.is_ascii_alphabetic()) {
|
||||
return Err("Currency code must contain only letters".to_string());
|
||||
}
|
||||
// Validate account type
|
||||
let _account_type: AccountType = self.account_type.clone();
|
||||
if matches!(self.account_type, AccountType::Other(ref s) if s.trim().is_empty()) {
|
||||
return Err("Account type cannot be empty".to_string());
|
||||
}
|
||||
if Decimal::from_str_exact(&self.initial_balance).is_err() {
|
||||
return Err("Initial balance must be a valid decimal number".to_string());
|
||||
}
|
||||
if let Some(ref color) = self.color {
|
||||
if !color.trim().is_empty() && !HEX_COLOR_PATTERN.is_match(color) {
|
||||
return Err(
|
||||
"Color must be a valid hex code (e.g., #FF0000 or #FF0000FF)".to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct UpdateAccountInput {
|
||||
pub name: Option<String>,
|
||||
pub account_type: Option<AccountType>,
|
||||
pub currency: Option<String>,
|
||||
pub initial_balance: Option<String>,
|
||||
// hex color code, e.g. #FF0000
|
||||
pub color: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub sort_order: Option<i32>,
|
||||
pub is_active: Option<bool>,
|
||||
pub is_archived: Option<bool>,
|
||||
pub include_in_net_worth: Option<bool>,
|
||||
pub show_in_combined_view: Option<bool>,
|
||||
}
|
||||
|
||||
impl UpdateAccountInput {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
// Validate name if provided
|
||||
if let Some(name) = &self.name {
|
||||
if name.trim().is_empty() {
|
||||
return Err("Account name cannot be empty".to_string());
|
||||
}
|
||||
if name.len() > 100 {
|
||||
return Err("Account name cannot exceed 100 characters".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Validate currency code if provided
|
||||
if let Some(currency) = &self.currency {
|
||||
let currency_upper = currency.to_uppercase();
|
||||
if currency_upper.len() != 3 {
|
||||
return Err("Currency code must be 3 characters (ISO 4217)".to_string());
|
||||
}
|
||||
if !currency_upper.chars().all(|c| c.is_ascii_alphabetic()) {
|
||||
return Err("Currency code must contain only letters".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Validate account type if provided
|
||||
if let Some(account_type) = &self.account_type {
|
||||
if matches!(account_type, AccountType::Other(s) if s.trim().is_empty()) {
|
||||
return Err("Account type cannot be empty".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Validate color format if provided
|
||||
if let Some(color) = &self.color {
|
||||
if !color.trim().is_empty() && !HEX_COLOR_PATTERN.is_match(color) {
|
||||
return Err(
|
||||
"Color must be a valid hex code (e.g., #FF0000 or #FF0000FF)".to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate initial_balance if provided
|
||||
if let Some(balance) = &self.initial_balance {
|
||||
if Decimal::from_str_exact(balance).is_err() {
|
||||
return Err("Initial balance must be a valid decimal number".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct AccountFilter {
|
||||
pub account_type: Option<AccountType>,
|
||||
pub include_archived: Option<bool>,
|
||||
pub limit: Option<u64>,
|
||||
pub offset: Option<u64>,
|
||||
}
|
||||
2
src-tauri/src/services/accounts/types/mod.rs
Normal file
2
src-tauri/src/services/accounts/types/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod inputs;
|
||||
pub mod outputs;
|
||||
7
src-tauri/src/services/accounts/types/outputs.rs
Normal file
7
src-tauri/src/services/accounts/types/outputs.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AccountBalance {
|
||||
pub amount: String,
|
||||
pub currency: String,
|
||||
}
|
||||
1
src-tauri/src/services/balance_calculator/mod.rs
Normal file
1
src-tauri/src/services/balance_calculator/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod service;
|
||||
434
src-tauri/src/services/balance_calculator/service.rs
Normal file
434
src-tauri/src/services/balance_calculator/service.rs
Normal file
@@ -0,0 +1,434 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use rust_decimal::Decimal;
|
||||
use sea_orm::{entity::*, query::*};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::db::{
|
||||
connection::ConnectionSource,
|
||||
entities::{accounts, prelude::*, transactions},
|
||||
};
|
||||
use crate::errors::{AppError, CommandResult};
|
||||
|
||||
/// Transaction types supported by the system
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TransactionType {
|
||||
/// Money entering an account (e.g., salary, refunds)
|
||||
Income,
|
||||
/// Money leaving an account (e.g., purchases, bills)
|
||||
Expense,
|
||||
/// Money transferred into this account from another account
|
||||
TransferIn,
|
||||
/// Money transferred out of this account to another account
|
||||
TransferOut,
|
||||
}
|
||||
|
||||
impl TransactionType {
|
||||
/// Returns true if this transaction type increases the balance
|
||||
/// (for standard asset accounts)
|
||||
pub fn is_balance_increasing(self) -> bool {
|
||||
matches!(self, TransactionType::Income | TransactionType::TransferIn)
|
||||
}
|
||||
|
||||
/// Returns true if this transaction type decreases the balance
|
||||
/// (for standard asset accounts)
|
||||
pub fn is_balance_decreasing(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
TransactionType::Expense | TransactionType::TransferOut
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns true if this is a transfer type
|
||||
pub fn is_transfer(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
TransactionType::TransferIn | TransactionType::TransferOut
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for TransactionType {
|
||||
type Err = AppError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"income" => Ok(TransactionType::Income),
|
||||
"expense" => Ok(TransactionType::Expense),
|
||||
"transfer_in" => Ok(TransactionType::TransferIn),
|
||||
"transfer_out" => Ok(TransactionType::TransferOut),
|
||||
_ => Err(AppError::Validation(format!(
|
||||
"Invalid transaction type: {}. Expected one of: income, expense, transfer_in, transfer_out",
|
||||
s
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TransactionType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
TransactionType::Income => write!(f, "income"),
|
||||
TransactionType::Expense => write!(f, "expense"),
|
||||
TransactionType::TransferIn => write!(f, "transfer_in"),
|
||||
TransactionType::TransferOut => write!(f, "transfer_out"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if account type is a liability (credit card, loan)
|
||||
pub fn is_liability_account(account_type: &str) -> bool {
|
||||
matches!(account_type.to_lowercase().as_str(), "credit_card" | "loan")
|
||||
}
|
||||
|
||||
/// Apply transaction amount to balance based on account type
|
||||
pub fn apply_transaction_to_balance(
|
||||
balance: &mut Decimal,
|
||||
amount: Decimal,
|
||||
txn_type: TransactionType,
|
||||
is_liability: bool,
|
||||
) {
|
||||
if is_liability {
|
||||
// For liability accounts: expenses increase debt, payments decrease debt
|
||||
match txn_type {
|
||||
TransactionType::Expense | TransactionType::TransferOut => *balance += amount,
|
||||
TransactionType::Income | TransactionType::TransferIn => *balance -= amount,
|
||||
}
|
||||
} else {
|
||||
// For asset accounts: normal balance calculation
|
||||
match txn_type {
|
||||
TransactionType::Expense | TransactionType::TransferOut => *balance -= amount,
|
||||
TransactionType::Income | TransactionType::TransferIn => *balance += amount,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a balance calculation
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BalanceCalculationResult {
|
||||
pub balance: Decimal,
|
||||
pub transaction_count: usize,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait BalanceCalculator: Send + Sync {
|
||||
/// Calculate account balance by recalculating from all transactions
|
||||
/// This is the source of truth for balance calculations
|
||||
async fn calculate_balance(
|
||||
&self,
|
||||
account_id: &str,
|
||||
conn: &ConnectionSource<'_>,
|
||||
) -> CommandResult<BalanceCalculationResult>;
|
||||
|
||||
/// Calculate historical balance as of a specific date
|
||||
async fn calculate_historical_balance(
|
||||
&self,
|
||||
account_id: &str,
|
||||
as_of_date: &str,
|
||||
conn: &ConnectionSource<'_>,
|
||||
) -> CommandResult<BalanceCalculationResult>;
|
||||
}
|
||||
|
||||
pub struct BalanceCalculatorImpl;
|
||||
|
||||
impl BalanceCalculatorImpl {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BalanceCalculatorImpl {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BalanceCalculator for BalanceCalculatorImpl {
|
||||
async fn calculate_balance(
|
||||
&self,
|
||||
account_id: &str,
|
||||
conn: &ConnectionSource<'_>,
|
||||
) -> CommandResult<BalanceCalculationResult> {
|
||||
// Get account details
|
||||
let account = Accounts::find_by_id(account_id)
|
||||
.filter(accounts::Column::IsDeleted.eq(false))
|
||||
.one(conn)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Account {}", account_id)))?;
|
||||
|
||||
let is_liability = is_liability_account(&account.account_type);
|
||||
let mut balance = Decimal::from_str(&account.initial_balance)?;
|
||||
let mut transaction_count = 0;
|
||||
|
||||
// Get all non-deleted transactions for this account
|
||||
let txns = Transactions::find()
|
||||
.filter(transactions::Column::AccountId.eq(account_id))
|
||||
.filter(transactions::Column::IsDeleted.eq(false))
|
||||
.order_by_asc(transactions::Column::TransactionDate)
|
||||
.all(conn)
|
||||
.await?;
|
||||
|
||||
for txn in txns {
|
||||
let amount = Decimal::from_str(&txn.net_amount)?;
|
||||
let txn_type = TransactionType::from_str(&txn.transaction_type)?;
|
||||
apply_transaction_to_balance(&mut balance, amount, txn_type, is_liability);
|
||||
transaction_count += 1;
|
||||
}
|
||||
|
||||
Ok(BalanceCalculationResult {
|
||||
balance,
|
||||
transaction_count,
|
||||
})
|
||||
}
|
||||
|
||||
async fn calculate_historical_balance(
|
||||
&self,
|
||||
account_id: &str,
|
||||
as_of_date: &str,
|
||||
conn: &ConnectionSource<'_>,
|
||||
) -> CommandResult<BalanceCalculationResult> {
|
||||
// Validate date format (YYYY-MM-DD)
|
||||
if !regex::Regex::new(r"^\d{4}-\d{2}-\d{2}$")
|
||||
.map_err(|_| AppError::Validation("Invalid regex pattern".to_string()))?
|
||||
.is_match(as_of_date)
|
||||
{
|
||||
return Err(AppError::Validation(
|
||||
"Invalid date format. Expected YYYY-MM-DD".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Get account details
|
||||
let account = Accounts::find_by_id(account_id)
|
||||
.filter(accounts::Column::IsDeleted.eq(false))
|
||||
.one(conn)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Account {}", account_id)))?;
|
||||
|
||||
let is_liability = is_liability_account(&account.account_type);
|
||||
let mut balance = Decimal::from_str(&account.initial_balance)?;
|
||||
let mut transaction_count = 0;
|
||||
|
||||
// Get transactions up to and including the specified date
|
||||
let txns = Transactions::find()
|
||||
.filter(transactions::Column::AccountId.eq(account_id))
|
||||
.filter(transactions::Column::IsDeleted.eq(false))
|
||||
.filter(transactions::Column::TransactionDate.lte(as_of_date))
|
||||
.order_by_asc(transactions::Column::TransactionDate)
|
||||
.all(conn)
|
||||
.await?;
|
||||
|
||||
for txn in txns {
|
||||
let amount = Decimal::from_str(&txn.net_amount)?;
|
||||
let txn_type = TransactionType::from_str(&txn.transaction_type)?;
|
||||
apply_transaction_to_balance(&mut balance, amount, txn_type, is_liability);
|
||||
transaction_count += 1;
|
||||
}
|
||||
|
||||
Ok(BalanceCalculationResult {
|
||||
balance,
|
||||
transaction_count,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_transaction_type_from_str() {
|
||||
assert_eq!(
|
||||
TransactionType::from_str("income").expect("Valid type"),
|
||||
TransactionType::Income
|
||||
);
|
||||
assert_eq!(
|
||||
TransactionType::from_str("expense").expect("Valid type"),
|
||||
TransactionType::Expense
|
||||
);
|
||||
assert_eq!(
|
||||
TransactionType::from_str("transfer_in").expect("Valid type"),
|
||||
TransactionType::TransferIn
|
||||
);
|
||||
assert_eq!(
|
||||
TransactionType::from_str("transfer_out").expect("Valid type"),
|
||||
TransactionType::TransferOut
|
||||
);
|
||||
|
||||
// Case insensitive
|
||||
assert_eq!(
|
||||
TransactionType::from_str("INCOME").expect("Valid type"),
|
||||
TransactionType::Income
|
||||
);
|
||||
assert_eq!(
|
||||
TransactionType::from_str("Expense").expect("Valid type"),
|
||||
TransactionType::Expense
|
||||
);
|
||||
|
||||
// Invalid type
|
||||
assert!(TransactionType::from_str("invalid").is_err());
|
||||
assert!(TransactionType::from_str("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transaction_type_display() {
|
||||
assert_eq!(TransactionType::Income.to_string(), "income");
|
||||
assert_eq!(TransactionType::Expense.to_string(), "expense");
|
||||
assert_eq!(TransactionType::TransferIn.to_string(), "transfer_in");
|
||||
assert_eq!(TransactionType::TransferOut.to_string(), "transfer_out");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transaction_type_helpers() {
|
||||
assert!(TransactionType::Income.is_balance_increasing());
|
||||
assert!(TransactionType::TransferIn.is_balance_increasing());
|
||||
assert!(!TransactionType::Expense.is_balance_increasing());
|
||||
assert!(!TransactionType::TransferOut.is_balance_increasing());
|
||||
|
||||
assert!(!TransactionType::Income.is_balance_decreasing());
|
||||
assert!(!TransactionType::TransferIn.is_balance_decreasing());
|
||||
assert!(TransactionType::Expense.is_balance_decreasing());
|
||||
assert!(TransactionType::TransferOut.is_balance_decreasing());
|
||||
|
||||
assert!(TransactionType::TransferIn.is_transfer());
|
||||
assert!(TransactionType::TransferOut.is_transfer());
|
||||
assert!(!TransactionType::Income.is_transfer());
|
||||
assert!(!TransactionType::Expense.is_transfer());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_liability_account() {
|
||||
assert!(is_liability_account("credit_card"));
|
||||
assert!(is_liability_account("CREDIT_CARD"));
|
||||
assert!(is_liability_account("Credit_Card"));
|
||||
assert!(is_liability_account("loan"));
|
||||
assert!(is_liability_account("LOAN"));
|
||||
assert!(!is_liability_account("checking"));
|
||||
assert!(!is_liability_account("savings"));
|
||||
assert!(!is_liability_account("cash"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_transaction_to_balance_asset() {
|
||||
let mut balance = Decimal::from(1000);
|
||||
|
||||
// Expense decreases balance
|
||||
apply_transaction_to_balance(
|
||||
&mut balance,
|
||||
Decimal::from(100),
|
||||
TransactionType::Expense,
|
||||
false,
|
||||
);
|
||||
assert_eq!(balance, Decimal::from(900));
|
||||
|
||||
// Income increases balance
|
||||
apply_transaction_to_balance(
|
||||
&mut balance,
|
||||
Decimal::from(200),
|
||||
TransactionType::Income,
|
||||
false,
|
||||
);
|
||||
assert_eq!(balance, Decimal::from(1100));
|
||||
|
||||
// Transfer out decreases balance
|
||||
apply_transaction_to_balance(
|
||||
&mut balance,
|
||||
Decimal::from(50),
|
||||
TransactionType::TransferOut,
|
||||
false,
|
||||
);
|
||||
assert_eq!(balance, Decimal::from(1050));
|
||||
|
||||
// Transfer in increases balance
|
||||
apply_transaction_to_balance(
|
||||
&mut balance,
|
||||
Decimal::from(150),
|
||||
TransactionType::TransferIn,
|
||||
false,
|
||||
);
|
||||
assert_eq!(balance, Decimal::from(1200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_transaction_to_balance_liability() {
|
||||
let mut balance = Decimal::from(-1000); // Negative balance = debt
|
||||
|
||||
// Expense increases debt (balance becomes more negative)
|
||||
apply_transaction_to_balance(
|
||||
&mut balance,
|
||||
Decimal::from(100),
|
||||
TransactionType::Expense,
|
||||
true,
|
||||
);
|
||||
assert_eq!(balance, Decimal::from(-900));
|
||||
|
||||
// Income decreases debt (balance becomes less negative)
|
||||
apply_transaction_to_balance(
|
||||
&mut balance,
|
||||
Decimal::from(200),
|
||||
TransactionType::Income,
|
||||
true,
|
||||
);
|
||||
assert_eq!(balance, Decimal::from(-1100));
|
||||
|
||||
// Transfer out increases debt
|
||||
apply_transaction_to_balance(
|
||||
&mut balance,
|
||||
Decimal::from(50),
|
||||
TransactionType::TransferOut,
|
||||
true,
|
||||
);
|
||||
assert_eq!(balance, Decimal::from(-1050));
|
||||
|
||||
// Transfer in decreases debt
|
||||
apply_transaction_to_balance(
|
||||
&mut balance,
|
||||
Decimal::from(150),
|
||||
TransactionType::TransferIn,
|
||||
true,
|
||||
);
|
||||
assert_eq!(balance, Decimal::from(-1200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transaction_type_serde() {
|
||||
// Test serialization
|
||||
assert_eq!(
|
||||
serde_json::to_string(&TransactionType::Income).expect("Serialize"),
|
||||
"\"income\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&TransactionType::Expense).expect("Serialize"),
|
||||
"\"expense\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&TransactionType::TransferIn).expect("Serialize"),
|
||||
"\"transfer_in\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&TransactionType::TransferOut).expect("Serialize"),
|
||||
"\"transfer_out\""
|
||||
);
|
||||
|
||||
// Test deserialization
|
||||
assert_eq!(
|
||||
serde_json::from_str::<TransactionType>("\"income\"").expect("Deserialize"),
|
||||
TransactionType::Income
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<TransactionType>("\"expense\"").expect("Deserialize"),
|
||||
TransactionType::Expense
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<TransactionType>("\"transfer_in\"").expect("Deserialize"),
|
||||
TransactionType::TransferIn
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<TransactionType>("\"transfer_out\"").expect("Deserialize"),
|
||||
TransactionType::TransferOut
|
||||
);
|
||||
}
|
||||
}
|
||||
462
src-tauri/src/services/exchange_rate/adapters/exchange_api.rs
Normal file
462
src-tauri/src/services/exchange_rate/adapters/exchange_api.rs
Normal file
@@ -0,0 +1,462 @@
|
||||
// Based on https://github.com/fawazahmed0/exchange-api
|
||||
// API: https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/{currency}.json
|
||||
|
||||
use rust_decimal::Decimal;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::ExchangeRateAdapter;
|
||||
use crate::{
|
||||
errors::{AppError, CommandResult},
|
||||
services::exchange_rate::adapters::ExchangeRateAdapterInfo,
|
||||
};
|
||||
|
||||
const API_BASE_URL: &str =
|
||||
"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies";
|
||||
const ADAPTER_NAME: &str = "exchange_api";
|
||||
const ADAPTER_DISPLAY_NAME: &str = "Exchange API";
|
||||
const ADAPTER_DESCRIPTION: &str = "A free API for current and historical exchange rates.";
|
||||
|
||||
pub(super) struct ExchangeApiAdapter;
|
||||
|
||||
impl ExchangeApiAdapter {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[cfg_attr(test, derive(serde::Serialize))]
|
||||
/// {date: "2024-06-01", 'currency_code': {"target_currency_code": rate, ...}}
|
||||
pub(super) struct ExchangeRateResponse {
|
||||
pub date: String,
|
||||
// currency code to exchange rate mapping
|
||||
#[serde(flatten)]
|
||||
pub rates: HashMap<String, HashMap<String, f64>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ExchangeRateAdapter for ExchangeApiAdapter {
|
||||
fn get_info() -> ExchangeRateAdapterInfo {
|
||||
ExchangeRateAdapterInfo {
|
||||
name: ADAPTER_NAME.to_string(),
|
||||
display_name: ADAPTER_DISPLAY_NAME.to_string(),
|
||||
description: ADAPTER_DESCRIPTION.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_supported_currencies(&self, base_currency: Option<&str>) -> Vec<String> {
|
||||
// The API supports a wide range of currencies. We'll fetch from a common base
|
||||
// and extract the available currency codes.
|
||||
// As a fallback, return a comprehensive list of common currencies.
|
||||
let base = base_currency.unwrap_or("usd").to_lowercase();
|
||||
match fetch_currency_data(&base).await {
|
||||
Ok(data) => {
|
||||
let mut currencies: Vec<String> = data
|
||||
.rates
|
||||
.get(&base)
|
||||
.map(|rates| rates.keys().cloned().collect())
|
||||
.unwrap_or_default();
|
||||
currencies.sort();
|
||||
currencies
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Failed to fetch supported currencies: {}", err);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_exchange_rate(
|
||||
&self,
|
||||
from_currency: &str,
|
||||
to_currency: &str,
|
||||
) -> CommandResult<(Decimal, u64)> {
|
||||
let from_lower = from_currency.to_lowercase();
|
||||
let to_lower = to_currency.to_lowercase();
|
||||
|
||||
let data = fetch_currency_data(&from_lower).await?;
|
||||
|
||||
// Look up the rate for the target currency
|
||||
if let Some(rates) = data.rates.get(&from_lower) {
|
||||
if let Some(rate) = rates.get(&to_lower) {
|
||||
// Convert f64 to Decimal for precision
|
||||
let decimal_rate = Decimal::from_f64_retain(*rate).ok_or_else(|| {
|
||||
AppError::Internal("Failed to convert exchange rate to Decimal".to_string())
|
||||
})?;
|
||||
// Convert date string to unix timestamp (using the date of the API response)
|
||||
let timestamp = chrono::NaiveDate::parse_from_str(&data.date, "%Y-%m-%d")
|
||||
.map_err(|e| {
|
||||
AppError::Internal(format!("Failed to parse date from API response: {}", e))
|
||||
})?
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.ok_or_else(|| {
|
||||
AppError::Internal(format!(
|
||||
"Failed to create datetime from date: {}",
|
||||
data.date
|
||||
))
|
||||
})?
|
||||
.and_utc()
|
||||
.timestamp() as u64;
|
||||
return Ok((decimal_rate, timestamp));
|
||||
}
|
||||
}
|
||||
|
||||
Err(AppError::NotFound(format!(
|
||||
"Exchange rate not found for {}/{}",
|
||||
from_currency, to_currency
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_currency_data(currency: &str) -> CommandResult<ExchangeRateResponse> {
|
||||
let url = format!("{}/{}.min.json", API_BASE_URL, currency);
|
||||
|
||||
let response = reqwest::get(&url)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Failed to fetch exchange rate: {}", e)))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(AppError::Internal(format!(
|
||||
"Exchange rate API returned status: {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let data: ExchangeRateResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Failed to parse API response: {}", e)))?;
|
||||
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Datelike;
|
||||
use rust_decimal::Decimal;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn test_exchange_api_adapter_new() {
|
||||
let adapter = ExchangeApiAdapter::new();
|
||||
// Just verify it can be created - the struct has no fields
|
||||
let _ = adapter;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_info() {
|
||||
let info = ExchangeApiAdapter::get_info();
|
||||
assert_eq!(info.name, ADAPTER_NAME);
|
||||
assert_eq!(info.display_name, ADAPTER_DISPLAY_NAME);
|
||||
assert_eq!(info.description, ADAPTER_DESCRIPTION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exchange_rate_response_deserialization() {
|
||||
let json_data = r#"{
|
||||
"date": "2024-01-15",
|
||||
"usd": {
|
||||
"eur": 0.92,
|
||||
"gbp": 0.79,
|
||||
"jpy": 148.50
|
||||
}
|
||||
}"#;
|
||||
|
||||
let response: ExchangeRateResponse =
|
||||
serde_json::from_str(json_data).expect("Failed to deserialize ExchangeRateResponse");
|
||||
assert_eq!(response.date, "2024-01-15");
|
||||
assert!(response.rates.contains_key("usd"));
|
||||
|
||||
let usd_rates = response.rates.get("usd").expect("USD rates not found");
|
||||
assert_eq!(usd_rates.get("eur"), Some(&0.92));
|
||||
assert_eq!(usd_rates.get("gbp"), Some(&0.79));
|
||||
assert_eq!(usd_rates.get("jpy"), Some(&148.50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exchange_rate_response_with_many_currencies() {
|
||||
let json_data = r#"{
|
||||
"date": "2024-01-15",
|
||||
"eur": {
|
||||
"usd": 1.09,
|
||||
"gbp": 0.86,
|
||||
"jpy": 161.20,
|
||||
"cad": 1.47,
|
||||
"aud": 1.65,
|
||||
"chf": 0.94
|
||||
}
|
||||
}"#;
|
||||
|
||||
let response: ExchangeRateResponse =
|
||||
serde_json::from_str(json_data).expect("Failed to deserialize ExchangeRateResponse");
|
||||
assert_eq!(response.date, "2024-01-15");
|
||||
|
||||
let eur_rates = response.rates.get("eur").expect("EUR rates not found");
|
||||
assert_eq!(eur_rates.len(), 6);
|
||||
assert!(eur_rates.contains_key("usd"));
|
||||
assert!(eur_rates.contains_key("jpy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exchange_rate_response_empty_rates() {
|
||||
let json_data = r#"{
|
||||
"date": "2024-01-15",
|
||||
"usd": {}
|
||||
}"#;
|
||||
|
||||
let response: ExchangeRateResponse =
|
||||
serde_json::from_str(json_data).expect("Failed to deserialize ExchangeRateResponse");
|
||||
assert_eq!(response.date, "2024-01-15");
|
||||
let usd_rates = response.rates.get("usd").expect("USD rates not found");
|
||||
assert!(usd_rates.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decimal_rate_parsing() {
|
||||
// Test that we can parse rates with high precision
|
||||
let rate_str = "0.921234567890";
|
||||
let rate = Decimal::from_str_exact(rate_str).expect("Failed to parse decimal rate");
|
||||
assert_eq!(rate.to_string(), rate_str);
|
||||
|
||||
// Test with larger rate
|
||||
let large_rate_str = "148.5012345678";
|
||||
let large_rate =
|
||||
Decimal::from_str_exact(large_rate_str).expect("Failed to parse large decimal rate");
|
||||
assert_eq!(large_rate.to_string(), large_rate_str);
|
||||
|
||||
// Test with very small rate
|
||||
let small_rate_str = "0.00000001";
|
||||
let small_rate =
|
||||
Decimal::from_str_exact(small_rate_str).expect("Failed to parse small decimal rate");
|
||||
assert_eq!(small_rate.to_string(), small_rate_str);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_date_parsing() {
|
||||
let date_str = "2024-01-15";
|
||||
let parsed_date =
|
||||
chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d").expect("Failed to parse date");
|
||||
|
||||
assert_eq!(parsed_date.year(), 2024);
|
||||
assert_eq!(parsed_date.month(), 1);
|
||||
assert_eq!(parsed_date.day(), 15);
|
||||
|
||||
// Test conversion to datetime
|
||||
let datetime = parsed_date
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.expect("Failed to create datetime")
|
||||
.and_utc();
|
||||
|
||||
assert_eq!(datetime.timestamp() as u64, 1705276800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_date_parsing_different_formats() {
|
||||
// Test year boundary
|
||||
let date_str = "2024-12-31";
|
||||
let parsed = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
|
||||
.expect("Failed to parse year boundary date");
|
||||
assert_eq!(parsed.year(), 2024);
|
||||
assert_eq!(parsed.month(), 12);
|
||||
assert_eq!(parsed.day(), 31);
|
||||
|
||||
// Test leap year
|
||||
let date_str = "2024-02-29";
|
||||
let parsed = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
|
||||
.expect("Failed to parse leap year date");
|
||||
assert_eq!(parsed.month(), 2);
|
||||
assert_eq!(parsed.day(), 29);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exchange_rate_response_serde_roundtrip() {
|
||||
let mut rates = HashMap::new();
|
||||
let mut inner_rates = HashMap::new();
|
||||
inner_rates.insert("eur".to_string(), 0.92_f64);
|
||||
inner_rates.insert("gbp".to_string(), 0.79_f64);
|
||||
rates.insert("usd".to_string(), inner_rates);
|
||||
|
||||
let response = ExchangeRateResponse {
|
||||
date: "2024-01-15".to_string(),
|
||||
rates,
|
||||
};
|
||||
|
||||
let json =
|
||||
serde_json::to_string(&response).expect("Failed to serialize ExchangeRateResponse");
|
||||
let deserialized: ExchangeRateResponse =
|
||||
serde_json::from_str(&json).expect("Failed to deserialize ExchangeRateResponse");
|
||||
|
||||
assert_eq!(deserialized.date, response.date);
|
||||
assert!(deserialized.rates.contains_key("usd"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_adapter_implements_exchange_rate_adapter() {
|
||||
// Compile-time check that ExchangeApiAdapter implements ExchangeRateAdapter
|
||||
fn check_adapter<T: ExchangeRateAdapter>() {}
|
||||
check_adapter::<ExchangeApiAdapter>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exchange_rate_response_debug() {
|
||||
let json_data = r#"{"date": "2024-01-15", "usd": {"eur": 0.92}}"#;
|
||||
let response: ExchangeRateResponse =
|
||||
serde_json::from_str(json_data).expect("Failed to deserialize ExchangeRateResponse");
|
||||
|
||||
let debug_str = format!("{:?}", response);
|
||||
assert!(debug_str.contains("2024-01-15"));
|
||||
assert!(debug_str.contains("usd"));
|
||||
}
|
||||
|
||||
/// Integration tests that call the actual API
|
||||
/// These tests are marked with #[ignore] to avoid running them in CI
|
||||
/// Run with: cargo test --package tauri-app -- --ignored
|
||||
mod integration_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Calls external API - run manually with: cargo test -- --ignored"]
|
||||
async fn test_fetch_currency_data_real_api() {
|
||||
let result = fetch_currency_data("usd").await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to fetch currency data: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
let data = result.expect("Failed to get currency data");
|
||||
assert!(!data.date.is_empty(), "Date should not be empty");
|
||||
assert!(!data.rates.is_empty(), "Rates should not be empty");
|
||||
|
||||
// Verify USD rates exist
|
||||
assert!(data.rates.contains_key("usd"), "USD rates should exist");
|
||||
let usd_rates = data.rates.get("usd").expect("USD rates not found");
|
||||
|
||||
// Verify common currencies exist in USD rates
|
||||
assert!(usd_rates.contains_key("eur"), "EUR rate should exist");
|
||||
assert!(usd_rates.contains_key("gbp"), "GBP rate should exist");
|
||||
assert!(usd_rates.contains_key("jpy"), "JPY rate should exist");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Calls external API - run manually with: cargo test -- --ignored"]
|
||||
async fn test_fetch_currency_data_eur_base() {
|
||||
let result = fetch_currency_data("eur").await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to fetch EUR currency data: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
let data = result.expect("Failed to get currency data");
|
||||
assert!(data.rates.contains_key("eur"), "EUR rates should exist");
|
||||
|
||||
let eur_rates = data.rates.get("eur").expect("EUR rates not found");
|
||||
assert!(eur_rates.contains_key("usd"), "USD rate should exist");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Calls external API - run manually with: cargo test -- --ignored"]
|
||||
async fn test_adapter_get_exchange_rate_real_api() {
|
||||
let adapter = ExchangeApiAdapter::new();
|
||||
|
||||
let result = adapter.get_exchange_rate("usd", "eur").await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to get exchange rate: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
let (rate, timestamp) = result.expect("Failed to get exchange rate");
|
||||
assert!(rate > Decimal::ZERO, "Rate should be positive");
|
||||
assert!(timestamp > 0, "Timestamp should be valid");
|
||||
|
||||
// USD to EUR should be less than 1
|
||||
assert!(rate < Decimal::ONE, "USD to EUR rate should be less than 1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Calls external API - run manually with: cargo test -- --ignored"]
|
||||
async fn test_adapter_get_supported_currencies_real_api() {
|
||||
let adapter = ExchangeApiAdapter::new();
|
||||
|
||||
let currencies = adapter.get_supported_currencies(Some("usd")).await;
|
||||
assert!(!currencies.is_empty(), "Should have supported currencies");
|
||||
|
||||
// Check for common currencies
|
||||
assert!(
|
||||
currencies.contains(&"eur".to_string()),
|
||||
"Should contain eur"
|
||||
);
|
||||
assert!(
|
||||
currencies.contains(&"gbp".to_string()),
|
||||
"Should contain gbp"
|
||||
);
|
||||
assert!(
|
||||
currencies.contains(&"jpy".to_string()),
|
||||
"Should contain jpy"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Calls external API - run manually with: cargo test -- --ignored"]
|
||||
async fn test_adapter_currency_conversion_roundtrip() {
|
||||
let adapter = ExchangeApiAdapter::new();
|
||||
|
||||
// Get USD to EUR rate
|
||||
let (usd_to_eur, _) = adapter
|
||||
.get_exchange_rate("usd", "eur")
|
||||
.await
|
||||
.expect("Failed to get USD to EUR rate");
|
||||
|
||||
// Get EUR to USD rate
|
||||
let (eur_to_usd, _) = adapter
|
||||
.get_exchange_rate("eur", "usd")
|
||||
.await
|
||||
.expect("Failed to get EUR to USD rate");
|
||||
|
||||
// The product of the two rates should be approximately 1
|
||||
let product = usd_to_eur * eur_to_usd;
|
||||
let one = Decimal::ONE;
|
||||
let diff = (product - one).abs();
|
||||
|
||||
// Allow for small differences (0.5% tolerance) due to API rate variations
|
||||
assert!(
|
||||
diff < Decimal::from_str_exact("0.005").expect("Failed to parse tolerance"),
|
||||
"Roundtrip conversion should be approximately 1, got: {}",
|
||||
product
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Calls external API - run manually with: cargo test -- --ignored"]
|
||||
async fn test_real_rate_decimal_parsing() {
|
||||
let result = fetch_currency_data("usd").await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let data = result.expect("Failed to get currency data");
|
||||
let usd_rates = data.rates.get("usd").expect("USD rates not found");
|
||||
|
||||
// Verify all rates can be converted to Decimal
|
||||
for (currency, rate_f64) in usd_rates {
|
||||
let rate = Decimal::from_f64_retain(*rate_f64);
|
||||
assert!(
|
||||
rate.is_some(),
|
||||
"Failed to convert rate for {}: {}",
|
||||
currency,
|
||||
rate_f64
|
||||
);
|
||||
|
||||
let rate = rate.expect("Decimal conversion failed");
|
||||
assert!(
|
||||
rate > Decimal::ZERO,
|
||||
"Rate for {} should be positive",
|
||||
currency
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
// Based on https://www.exchangerate-api.com/docs/free
|
||||
// API: https://api.exchangerate-api.com/v4/latest/{base_currency}
|
||||
|
||||
use rust_decimal::Decimal;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::ExchangeRateAdapter;
|
||||
use crate::{
|
||||
errors::{AppError, CommandResult},
|
||||
services::exchange_rate::adapters::ExchangeRateAdapterInfo,
|
||||
};
|
||||
|
||||
const API_BASE_URL: &str = "https://api.exchangerate-api.com/v4/latest";
|
||||
const ADAPTER_NAME: &str = "exchange_rate_api";
|
||||
const ADAPTER_DISPLAY_NAME: &str = "ExchangeRate-API";
|
||||
const ADAPTER_DESCRIPTION: &str = "A free API for current and historical exchange rates. {\"tag\": \"<a href='https://www.exchangerate-api.com'>Rates By Exchange Rate API</a>\"}";
|
||||
|
||||
pub(super) struct ExchangeRateApiAdapter;
|
||||
|
||||
impl ExchangeRateApiAdapter {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[cfg_attr(test, derive(serde::Serialize))]
|
||||
pub(super) struct ExchangeRateResponse {
|
||||
#[serde(rename = "base")]
|
||||
pub base_code: String,
|
||||
#[serde(rename = "time_last_updated")]
|
||||
pub time_last_update_unix: u64,
|
||||
// currency code to exchange rate mapping
|
||||
pub rates: HashMap<String, f64>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ExchangeRateAdapter for ExchangeRateApiAdapter {
|
||||
fn get_info() -> ExchangeRateAdapterInfo {
|
||||
ExchangeRateAdapterInfo {
|
||||
name: ADAPTER_NAME.to_string(),
|
||||
display_name: ADAPTER_DISPLAY_NAME.to_string(),
|
||||
description: ADAPTER_DESCRIPTION.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_supported_currencies(&self, base_currency: Option<&str>) -> Vec<String> {
|
||||
// Fetch supported currencies by getting rates for USD
|
||||
let base = base_currency.unwrap_or("USD").to_uppercase();
|
||||
match fetch_exchange_rates(&base).await {
|
||||
Ok(data) => {
|
||||
let mut currencies: Vec<String> = data.rates.keys().cloned().collect();
|
||||
currencies.sort();
|
||||
currencies
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Failed to fetch supported currencies: {}", err);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_exchange_rate(
|
||||
&self,
|
||||
from_currency: &str,
|
||||
to_currency: &str,
|
||||
) -> CommandResult<(Decimal, u64)> {
|
||||
let from_upper = from_currency.to_uppercase();
|
||||
let to_upper = to_currency.to_uppercase();
|
||||
|
||||
let data = fetch_exchange_rates(&from_upper).await?;
|
||||
|
||||
// Look up the rate for the target currency
|
||||
if let Some(rate) = data.rates.get(&to_upper) {
|
||||
let decimal_rate = Decimal::from_f64_retain(*rate).ok_or_else(|| {
|
||||
AppError::Internal("Failed to convert exchange rate to Decimal".to_string())
|
||||
})?;
|
||||
Ok((decimal_rate, data.time_last_update_unix))
|
||||
} else {
|
||||
Err(AppError::NotFound(format!(
|
||||
"Exchange rate not found for {}/{}",
|
||||
from_currency, to_currency
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_exchange_rates(base_currency: &str) -> CommandResult<ExchangeRateResponse> {
|
||||
let url = format!("{}/{}", API_BASE_URL, base_currency.to_uppercase());
|
||||
|
||||
let response = reqwest::get(&url)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Failed to fetch exchange rates: {}", e)))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(AppError::Internal(format!(
|
||||
"Exchange rate API returned status: {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let data: ExchangeRateResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Failed to parse API response: {}", e)))?;
|
||||
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Datelike;
|
||||
use rust_decimal::Decimal;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn test_exchange_rate_api_adapter_new() {
|
||||
let adapter = ExchangeRateApiAdapter::new();
|
||||
// Just verify it can be created - the struct has no fields
|
||||
let _ = adapter;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_info() {
|
||||
let info = ExchangeRateApiAdapter::get_info();
|
||||
assert_eq!(info.name, ADAPTER_NAME);
|
||||
assert_eq!(info.display_name, ADAPTER_DISPLAY_NAME);
|
||||
assert_eq!(info.description, ADAPTER_DESCRIPTION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exchange_rate_response_deserialization() {
|
||||
let json_data = r#"{
|
||||
"base": "USD",
|
||||
"time_last_updated": 1704067200,
|
||||
"rates": {
|
||||
"EUR": 0.92,
|
||||
"GBP": 0.79,
|
||||
"JPY": 148.50
|
||||
}
|
||||
}"#;
|
||||
|
||||
let response: ExchangeRateResponse =
|
||||
serde_json::from_str(json_data).expect("Failed to deserialize JSON");
|
||||
assert_eq!(response.base_code, "USD");
|
||||
assert_eq!(response.time_last_update_unix, 1704067200);
|
||||
assert_eq!(response.rates.len(), 3);
|
||||
|
||||
assert_eq!(response.rates.get("EUR"), Some(&0.92_f64));
|
||||
assert_eq!(response.rates.get("GBP"), Some(&0.79_f64));
|
||||
assert_eq!(response.rates.get("JPY"), Some(&148.50_f64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exchange_rate_response_with_many_currencies() {
|
||||
let json_data = r#"{
|
||||
"base": "EUR",
|
||||
"time_last_updated": 1704067200,
|
||||
"rates": {
|
||||
"USD": 1.09,
|
||||
"GBP": 0.86,
|
||||
"JPY": 161.20,
|
||||
"CAD": 1.47,
|
||||
"AUD": 1.65,
|
||||
"CHF": 0.94
|
||||
}
|
||||
}"#;
|
||||
|
||||
let response: ExchangeRateResponse =
|
||||
serde_json::from_str(json_data).expect("Failed to deserialize JSON");
|
||||
assert_eq!(response.base_code, "EUR");
|
||||
assert_eq!(response.rates.len(), 6);
|
||||
assert!(response.rates.contains_key("USD"));
|
||||
assert!(response.rates.contains_key("JPY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exchange_rate_response_empty_rates() {
|
||||
let json_data = r#"{
|
||||
"base": "USD",
|
||||
"time_last_updated": 1704067200,
|
||||
"rates": {}
|
||||
}"#;
|
||||
|
||||
let response: ExchangeRateResponse =
|
||||
serde_json::from_str(json_data).expect("Failed to deserialize JSON");
|
||||
assert_eq!(response.base_code, "USD");
|
||||
assert!(response.rates.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decimal_precision_from_f64() {
|
||||
// Test that Decimal can represent rates from f64 precisely
|
||||
let f64_rate: f64 = 0.9215;
|
||||
let decimal_rate =
|
||||
Decimal::from_f64_retain(f64_rate).expect("Failed to retain f64 as Decimal");
|
||||
assert!(decimal_rate > Decimal::ZERO);
|
||||
|
||||
// Test with larger rate
|
||||
let large_f64: f64 = 148.50;
|
||||
let large_decimal =
|
||||
Decimal::from_f64_retain(large_f64).expect("Failed to retain f64 as Decimal");
|
||||
assert!(large_decimal > Decimal::ONE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_currency_case_normalization() {
|
||||
// The adapter should normalize currencies to uppercase
|
||||
let eur_lower = "eur";
|
||||
let eur_upper = "EUR";
|
||||
let eur_mixed = "Eur";
|
||||
|
||||
assert_eq!(eur_lower.to_uppercase(), "EUR");
|
||||
assert_eq!(eur_upper.to_uppercase(), "EUR");
|
||||
assert_eq!(eur_mixed.to_uppercase(), "EUR");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_timestamp_unix_conversion() {
|
||||
let timestamp: u64 = 1704067200; // 2024-01-01 00:00:00 UTC
|
||||
|
||||
// Verify the timestamp can be converted to DateTime
|
||||
let dt = chrono::DateTime::from_timestamp(timestamp as i64, 0);
|
||||
assert!(dt.is_some());
|
||||
|
||||
let dt = dt.expect("DateTime conversion failed");
|
||||
assert_eq!(dt.year(), 2024);
|
||||
assert_eq!(dt.month(), 1);
|
||||
assert_eq!(dt.day(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exchange_rate_response_serde_roundtrip() {
|
||||
let mut rates = HashMap::new();
|
||||
rates.insert("EUR".to_string(), 0.92_f64);
|
||||
rates.insert("GBP".to_string(), 0.79_f64);
|
||||
|
||||
let response = ExchangeRateResponse {
|
||||
base_code: "USD".to_string(),
|
||||
time_last_update_unix: 1704067200,
|
||||
rates,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&response).expect("Failed to serialize JSON");
|
||||
let deserialized: ExchangeRateResponse =
|
||||
serde_json::from_str(&json).expect("Failed to deserialize JSON");
|
||||
|
||||
assert_eq!(deserialized.base_code, response.base_code);
|
||||
assert_eq!(
|
||||
deserialized.time_last_update_unix,
|
||||
response.time_last_update_unix
|
||||
);
|
||||
assert_eq!(deserialized.rates.len(), response.rates.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_base_url_constant() {
|
||||
assert!(API_BASE_URL.contains("exchangerate-api.com"));
|
||||
assert!(API_BASE_URL.contains("v4/latest"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exchange_rate_response_debug() {
|
||||
let json_data = r#"{
|
||||
"base": "USD",
|
||||
"time_last_updated": 1704067200,
|
||||
"rates": {"EUR": 0.92}
|
||||
}"#;
|
||||
let response: ExchangeRateResponse =
|
||||
serde_json::from_str(json_data).expect("Failed to deserialize JSON");
|
||||
|
||||
let debug_str = format!("{:?}", response);
|
||||
assert!(debug_str.contains("USD"));
|
||||
assert!(debug_str.contains("1704067200"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decimal_roundtrip_conversion() {
|
||||
// Test that Decimal -> String -> Decimal preserves value
|
||||
let original =
|
||||
Decimal::from_f64_retain(1.23456789).expect("Failed to retain f64 as Decimal");
|
||||
let as_string = original.to_string();
|
||||
let parsed = Decimal::from_str_exact(&as_string).expect("Failed to parse rate string");
|
||||
|
||||
assert_eq!(original, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rate_lookups() {
|
||||
let mut rates = HashMap::new();
|
||||
rates.insert("USD".to_string(), Decimal::ONE);
|
||||
rates.insert(
|
||||
"EUR".to_string(),
|
||||
Decimal::from_f64_retain(0.92).expect("Failed to retain f64 as Decimal"),
|
||||
);
|
||||
rates.insert(
|
||||
"GBP".to_string(),
|
||||
Decimal::from_f64_retain(0.79).expect("Failed to retain f64 as Decimal"),
|
||||
);
|
||||
|
||||
// Test that we can look up rates
|
||||
assert!(rates.contains_key("USD"));
|
||||
assert!(rates.contains_key("EUR"));
|
||||
assert!(!rates.contains_key("JPY"));
|
||||
|
||||
// Test getting specific rate
|
||||
let usd_rate = rates.get("USD").expect("USD rate not found");
|
||||
assert_eq!(*usd_rate, Decimal::ONE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exchange_rate_response_with_special_currencies() {
|
||||
// Some currencies might have special handling
|
||||
let json_data = r#"{
|
||||
"base": "USD",
|
||||
"time_last_updated": 1704067200,
|
||||
"rates": {
|
||||
"XBT": 0.000023,
|
||||
"XAU": 0.00042
|
||||
}
|
||||
}"#;
|
||||
|
||||
let response: ExchangeRateResponse =
|
||||
serde_json::from_str(json_data).expect("Failed to deserialize JSON");
|
||||
assert_eq!(response.rates.len(), 2);
|
||||
assert!(response.rates.contains_key("XBT"));
|
||||
assert!(response.rates.contains_key("XAU"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decimal_from_str_exact_various_rates() {
|
||||
let test_cases = vec![
|
||||
"1.0",
|
||||
"0.5",
|
||||
"0.1234567890",
|
||||
"100.00",
|
||||
"0.000001",
|
||||
"999999.999999",
|
||||
];
|
||||
|
||||
for rate_str in test_cases {
|
||||
let result = Decimal::from_str_exact(rate_str);
|
||||
assert!(result.is_ok(), "Failed to parse: {}", rate_str);
|
||||
|
||||
let decimal = result.expect("Decimal parsing failed");
|
||||
assert_eq!(decimal.to_string(), rate_str);
|
||||
}
|
||||
}
|
||||
|
||||
/// Integration tests that call the actual API
|
||||
/// These tests are marked with #[ignore] to avoid running them in CI
|
||||
/// Run with: cargo test --package tauri-app -- --ignored
|
||||
mod integration_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Calls external API - run manually with: cargo test -- --ignored"]
|
||||
async fn test_fetch_exchange_rates_real_api() {
|
||||
let result = fetch_exchange_rates("USD").await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to fetch exchange rates: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
let data = result.expect("Failed to get exchange rates");
|
||||
assert_eq!(data.base_code, "USD");
|
||||
assert!(!data.rates.is_empty(), "Rates should not be empty");
|
||||
assert!(data.time_last_update_unix > 0, "Timestamp should be valid");
|
||||
|
||||
// Verify common currencies exist
|
||||
assert!(data.rates.contains_key("EUR"), "EUR rate should exist");
|
||||
assert!(data.rates.contains_key("GBP"), "GBP rate should exist");
|
||||
assert!(data.rates.contains_key("JPY"), "JPY rate should exist");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Calls external API - run manually with: cargo test -- --ignored"]
|
||||
async fn test_fetch_exchange_rates_eur_base() {
|
||||
let result = fetch_exchange_rates("EUR").await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to fetch EUR-based rates: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
let data = result.expect("Failed to get exchange rates");
|
||||
assert_eq!(data.base_code, "EUR");
|
||||
assert!(data.rates.contains_key("USD"), "USD rate should exist");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Calls external API - run manually with: cargo test -- --ignored"]
|
||||
async fn test_adapter_get_exchange_rate_real_api() {
|
||||
let adapter = ExchangeRateApiAdapter::new();
|
||||
|
||||
let result = adapter.get_exchange_rate("USD", "EUR").await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to get exchange rate: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
let (rate, timestamp) = result.expect("Failed to get exchange rate");
|
||||
assert!(rate > Decimal::ZERO, "Rate should be positive");
|
||||
assert!(timestamp > 0, "Timestamp should be valid");
|
||||
|
||||
// USD to EUR should be less than 1
|
||||
assert!(rate < Decimal::ONE, "USD to EUR rate should be less than 1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Calls external API - run manually with: cargo test -- --ignored"]
|
||||
async fn test_adapter_get_supported_currencies_real_api() {
|
||||
let adapter = ExchangeRateApiAdapter::new();
|
||||
|
||||
let currencies = adapter.get_supported_currencies(Some("USD")).await;
|
||||
assert!(!currencies.is_empty(), "Should have supported currencies");
|
||||
|
||||
// Check for common currencies
|
||||
assert!(
|
||||
currencies.contains(&"USD".to_string()),
|
||||
"Should contain USD"
|
||||
);
|
||||
assert!(
|
||||
currencies.contains(&"EUR".to_string()),
|
||||
"Should contain EUR"
|
||||
);
|
||||
assert!(
|
||||
currencies.contains(&"GBP".to_string()),
|
||||
"Should contain GBP"
|
||||
);
|
||||
assert!(
|
||||
currencies.contains(&"JPY".to_string()),
|
||||
"Should contain JPY"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Calls external API - run manually with: cargo test -- --ignored"]
|
||||
async fn test_adapter_currency_conversion_roundtrip() {
|
||||
let adapter = ExchangeRateApiAdapter::new();
|
||||
|
||||
// Get USD to EUR rate
|
||||
let (usd_to_eur, _) = adapter
|
||||
.get_exchange_rate("USD", "EUR")
|
||||
.await
|
||||
.expect("Failed to get USD to EUR rate");
|
||||
|
||||
// Get EUR to USD rate
|
||||
let (eur_to_usd, _) = adapter
|
||||
.get_exchange_rate("EUR", "USD")
|
||||
.await
|
||||
.expect("Failed to get EUR to USD rate");
|
||||
|
||||
// The product of the two rates should be approximately 1
|
||||
let product = usd_to_eur * eur_to_usd;
|
||||
let one = Decimal::ONE;
|
||||
let diff = (product - one).abs();
|
||||
|
||||
// Allow for small floating point differences (0.5% tolerance for real-world rates)
|
||||
assert!(
|
||||
diff < Decimal::from_str_exact("0.005").expect("Failed to parse tolerance"),
|
||||
"Roundtrip conversion should be approximately 1, got: {}",
|
||||
product
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
233
src-tauri/src/services/exchange_rate/adapters/mod.rs
Normal file
233
src-tauri/src/services/exchange_rate/adapters/mod.rs
Normal file
@@ -0,0 +1,233 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::errors::CommandResult;
|
||||
|
||||
pub(super) mod exchange_api;
|
||||
pub(super) mod exchange_rate_api;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct ExchangeRateAdapterInfo {
|
||||
pub name: String,
|
||||
pub display_name: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait ExchangeRateAdapter {
|
||||
fn get_info() -> ExchangeRateAdapterInfo
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
/// Get the exchange rate from `from_currency` to `to_currency`.
|
||||
/// Returns the exchange rate and the timestamp of the last update.
|
||||
async fn get_exchange_rate(
|
||||
&self,
|
||||
from_currency: &str,
|
||||
to_currency: &str,
|
||||
) -> CommandResult<(rust_decimal::Decimal, u64)>;
|
||||
|
||||
async fn get_supported_currencies(&self, base_currency: Option<&str>) -> Vec<String>;
|
||||
}
|
||||
|
||||
pub fn get_default_adapter() -> Arc<dyn ExchangeRateAdapter + Send + Sync> {
|
||||
Arc::new(exchange_api::ExchangeApiAdapter::new())
|
||||
}
|
||||
|
||||
pub fn get_adapter_info() -> Vec<ExchangeRateAdapterInfo> {
|
||||
vec![
|
||||
exchange_api::ExchangeApiAdapter::get_info(),
|
||||
exchange_rate_api::ExchangeRateApiAdapter::get_info(),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn get_adapter_by_name(name: &str) -> Option<Arc<dyn ExchangeRateAdapter + Send + Sync>> {
|
||||
match name {
|
||||
"exchange_api" => Some(Arc::new(exchange_api::ExchangeApiAdapter::new())),
|
||||
"exchange_rate_api" => Some(Arc::new(exchange_rate_api::ExchangeRateApiAdapter::new())),
|
||||
_ => {
|
||||
log::warn!("Unknown adapter name: {}", name);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Datelike;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
#[test]
|
||||
fn test_get_adapter_info_returns_both_adapters() {
|
||||
let names = get_adapter_info()
|
||||
.iter()
|
||||
.map(|info| info.name.clone())
|
||||
.collect::<Vec<String>>();
|
||||
assert_eq!(names.len(), 2);
|
||||
assert!(names.contains(&"exchange_api".to_string()));
|
||||
assert!(names.contains(&"exchange_rate_api".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_adapter_by_name_exchange_api() {
|
||||
let adapter = get_adapter_by_name("exchange_api");
|
||||
assert!(adapter.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_adapter_by_name_exchange_rate_api() {
|
||||
let adapter = get_adapter_by_name("exchange_rate_api");
|
||||
assert!(adapter.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_adapter_by_name_unknown() {
|
||||
let adapter = get_adapter_by_name("unknown_adapter");
|
||||
assert!(adapter.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_adapter_by_name_empty() {
|
||||
let adapter = get_adapter_by_name("");
|
||||
assert!(adapter.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exchange_api_adapter_new() {
|
||||
let _adapter = exchange_api::ExchangeApiAdapter::new();
|
||||
// Just verify it can be created
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exchange_rate_api_adapter_new() {
|
||||
let _adapter = exchange_rate_api::ExchangeRateApiAdapter::new();
|
||||
// Just verify it can be created
|
||||
}
|
||||
|
||||
// Test the response struct parsing for exchange_api
|
||||
#[test]
|
||||
fn test_exchange_api_response_deserialization() {
|
||||
use exchange_api::ExchangeRateResponse;
|
||||
|
||||
let json_data = r#"{
|
||||
"date": "2024-01-01",
|
||||
"usd": {
|
||||
"eur": 0.85,
|
||||
"gbp": 0.73
|
||||
}
|
||||
}"#;
|
||||
|
||||
let response: ExchangeRateResponse =
|
||||
serde_json::from_str(json_data).expect("Failed to deserialize ExchangeRateResponse");
|
||||
assert_eq!(response.date, "2024-01-01");
|
||||
assert!(response.rates.contains_key("usd"));
|
||||
|
||||
let usd_rates = response.rates.get("usd").expect("USD rates not found");
|
||||
assert_eq!(usd_rates.get("eur"), Some(&0.85_f64));
|
||||
assert_eq!(usd_rates.get("gbp"), Some(&0.73_f64));
|
||||
}
|
||||
|
||||
// Test the response struct parsing for exchange_rate_api
|
||||
#[test]
|
||||
fn test_exchange_rate_api_response_deserialization() {
|
||||
use exchange_rate_api::ExchangeRateResponse;
|
||||
|
||||
let json_data = r#"{
|
||||
"base": "USD",
|
||||
"time_last_updated": 1704067200,
|
||||
"rates": {
|
||||
"EUR": 0.85,
|
||||
"GBP": 0.73,
|
||||
"JPY": 150.5
|
||||
}
|
||||
}"#;
|
||||
|
||||
let response: ExchangeRateResponse =
|
||||
serde_json::from_str(json_data).expect("Failed to deserialize ExchangeRateResponse");
|
||||
assert_eq!(response.base_code, "USD");
|
||||
assert_eq!(response.time_last_update_unix, 1704067200);
|
||||
assert!(response.rates.contains_key("EUR"));
|
||||
|
||||
assert_eq!(response.rates.get("EUR"), Some(&0.85_f64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exchange_api_response_with_empty_rates() {
|
||||
use exchange_api::ExchangeRateResponse;
|
||||
|
||||
let json_data = r#"{
|
||||
"date": "2024-01-01",
|
||||
"usd": {}
|
||||
}"#;
|
||||
|
||||
let response: ExchangeRateResponse =
|
||||
serde_json::from_str(json_data).expect("Failed to deserialize ExchangeRateResponse");
|
||||
assert_eq!(response.date, "2024-01-01");
|
||||
assert!(
|
||||
response
|
||||
.rates
|
||||
.get("usd")
|
||||
.expect("USD rates not found")
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exchange_rate_api_response_with_many_currencies() {
|
||||
use exchange_rate_api::ExchangeRateResponse;
|
||||
|
||||
let json_data = r#"{
|
||||
"base": "EUR",
|
||||
"time_last_updated": 1704067200,
|
||||
"rates": {
|
||||
"USD": 1.18,
|
||||
"GBP": 0.86,
|
||||
"JPY": 160.2,
|
||||
"CAD": 1.58,
|
||||
"AUD": 1.62,
|
||||
"CHF": 0.94
|
||||
}
|
||||
}"#;
|
||||
|
||||
let response: ExchangeRateResponse =
|
||||
serde_json::from_str(json_data).expect("Failed to deserialize ExchangeRateResponse");
|
||||
assert_eq!(response.rates.len(), 6);
|
||||
assert!(response.rates.contains_key("USD"));
|
||||
assert!(response.rates.contains_key("JPY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decimal_parsing_from_api_responses() {
|
||||
// Test parsing string rates from exchange_api format
|
||||
let rate_str = "0.851234567890";
|
||||
let rate = Decimal::from_str_exact(rate_str).expect("Failed to parse decimal rate");
|
||||
assert_eq!(rate.to_string(), rate_str);
|
||||
|
||||
// Test precision is maintained
|
||||
let precise_rate =
|
||||
Decimal::from_f64_retain(0.12345678901234).expect("Failed to parse precise rate");
|
||||
assert!(precise_rate > Decimal::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exchange_api_response_date_format() {
|
||||
use exchange_api::ExchangeRateResponse;
|
||||
|
||||
let json_data = r#"{"date": "2024-12-31", "usd": {}}"#;
|
||||
let response: ExchangeRateResponse =
|
||||
serde_json::from_str(json_data).expect("Failed to deserialize ExchangeRateResponse");
|
||||
assert_eq!(response.date, "2024-12-31");
|
||||
|
||||
// Verify date can be parsed
|
||||
let parsed_date = chrono::NaiveDate::parse_from_str(&response.date, "%Y-%m-%d")
|
||||
.expect("Failed to parse date");
|
||||
assert!(parsed_date.year() == 2024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_adapter_trait_object_safety() {
|
||||
// Verify that ExchangeRateAdapter can be used as a trait object
|
||||
let _: Option<Arc<dyn ExchangeRateAdapter + Send + Sync>> = None;
|
||||
}
|
||||
}
|
||||
5
src-tauri/src/services/exchange_rate/mod.rs
Normal file
5
src-tauri/src/services/exchange_rate/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod adapters;
|
||||
pub mod service;
|
||||
|
||||
// reexporting for easier access
|
||||
pub use adapters::ExchangeRateAdapterInfo;
|
||||
565
src-tauri/src/services/exchange_rate/service.rs
Normal file
565
src-tauri/src/services/exchange_rate/service.rs
Normal file
@@ -0,0 +1,565 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use rust_decimal::Decimal;
|
||||
use sea_orm::{DatabaseConnection, Set, entity::*};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::{
|
||||
db::entities::{exchange_rates, prelude::*},
|
||||
errors::{AppError, CommandResult},
|
||||
services::{
|
||||
ServiceTrait, exchange_rate::adapters::ExchangeRateAdapterInfo,
|
||||
settings::service::SettingsService,
|
||||
},
|
||||
};
|
||||
|
||||
const PREFIX: &str = "exchange_adapter";
|
||||
const EXCHANGE_ADAPTER_SETTING_KEY: &str = "selected_adapter";
|
||||
const DEFAULT_ADAPTER_NAME: &str = "exchange_api";
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait ExchangeRateService: ServiceTrait {
|
||||
async fn get_exchange_rate(
|
||||
&self,
|
||||
from_currency: &str,
|
||||
to_currency: &str,
|
||||
) -> CommandResult<Decimal>;
|
||||
|
||||
async fn get_supported_currencies(&self) -> Vec<String>;
|
||||
|
||||
async fn get_available_adapters(&self) -> Vec<ExchangeRateAdapterInfo> {
|
||||
super::adapters::get_adapter_info()
|
||||
}
|
||||
|
||||
async fn set_adapter(&self, adapter_name: &str) -> CommandResult<()>;
|
||||
async fn get_current_adapter(&self) -> CommandResult<String>;
|
||||
}
|
||||
|
||||
pub struct ExchangeRateServiceImpl {
|
||||
db: DatabaseConnection,
|
||||
settings_service: Arc<crate::services::settings::service::SettingsServiceImpl>,
|
||||
adapter: RwLock<Arc<dyn super::adapters::ExchangeRateAdapter + Send + Sync>>,
|
||||
supported_currencies_cache: RwLock<Option<(Vec<String>, chrono::DateTime<chrono::Utc>)>>,
|
||||
}
|
||||
|
||||
impl ExchangeRateServiceImpl {
|
||||
pub async fn new(
|
||||
db: DatabaseConnection,
|
||||
settings_service: Arc<crate::services::settings::service::SettingsServiceImpl>,
|
||||
) -> Self {
|
||||
let adapter_name = settings_service
|
||||
.get_setting(
|
||||
&Self::get_settings_key(EXCHANGE_ADAPTER_SETTING_KEY),
|
||||
DEFAULT_ADAPTER_NAME,
|
||||
)
|
||||
.await;
|
||||
|
||||
let adapter = super::adapters::get_adapter_by_name(&adapter_name).unwrap_or_else(|| {
|
||||
log::warn!(
|
||||
"Adapter '{}' not found, falling back to default adapter '{}'",
|
||||
adapter_name,
|
||||
DEFAULT_ADAPTER_NAME
|
||||
);
|
||||
super::adapters::get_default_adapter()
|
||||
});
|
||||
|
||||
Self {
|
||||
db,
|
||||
settings_service,
|
||||
adapter: RwLock::new(adapter),
|
||||
supported_currencies_cache: RwLock::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_settings_key(key: &str) -> String {
|
||||
format!("{}:{}", PREFIX, key)
|
||||
}
|
||||
|
||||
async fn store_exchange_rate(
|
||||
&self,
|
||||
from_currency: &str,
|
||||
to_currency: &str,
|
||||
rate: Decimal,
|
||||
timestamp: u64,
|
||||
) -> CommandResult<()> {
|
||||
let today = Utc::now().date_naive().to_string();
|
||||
let fetched_at = chrono::DateTime::from_timestamp(timestamp as i64, 0)
|
||||
.ok_or_else(|| AppError::InvalidData("Invalid timestamp".into()))?
|
||||
.naive_utc();
|
||||
|
||||
let active_model = exchange_rates::ActiveModel {
|
||||
from_currency: Set(from_currency.to_string()),
|
||||
to_currency: Set(to_currency.to_string()),
|
||||
date: Set(today),
|
||||
rate: Set(rate.to_string()),
|
||||
source: Set(None),
|
||||
fetched_at: Set(Some(fetched_at)),
|
||||
};
|
||||
|
||||
// Use upsert to handle conflicts on the composite primary key
|
||||
let _ = ExchangeRates::insert(active_model)
|
||||
.on_conflict(
|
||||
sea_orm::sea_query::OnConflict::new()
|
||||
.update_column(exchange_rates::Column::Rate)
|
||||
.update_column(exchange_rates::Column::FetchedAt)
|
||||
.to_owned(),
|
||||
)
|
||||
.exec(&self.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_cached_exchange_rate(
|
||||
&self,
|
||||
from_currency: &str,
|
||||
to_currency: &str,
|
||||
) -> CommandResult<Option<(Decimal, chrono::DateTime<chrono::Utc>)>> {
|
||||
let today = Utc::now().date_naive().to_string();
|
||||
|
||||
let result =
|
||||
ExchangeRates::find_by_id((from_currency.to_string(), to_currency.to_string(), today))
|
||||
.one(&self.db)
|
||||
.await?;
|
||||
|
||||
if let Some(model) = result {
|
||||
let rate = Decimal::from_str_exact(&model.rate)
|
||||
.ok()
|
||||
.ok_or_else(|| AppError::InvalidData("Failed to parse exchange rate".into()))?;
|
||||
let timestamp = model
|
||||
.fetched_at
|
||||
.ok_or_else(|| AppError::InvalidData("Missing fetched_at timestamp".into()))?;
|
||||
// Convert NaiveDateTime to DateTime<Utc>
|
||||
let timestamp_utc = chrono::DateTime::from_naive_utc_and_offset(timestamp, chrono::Utc);
|
||||
Ok(Some((rate, timestamp_utc)))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ServiceTrait for ExchangeRateServiceImpl {
|
||||
async fn on_app_start(&self) -> CommandResult<()> {
|
||||
// Ensure the default adapter is set in settings if not already set
|
||||
let current_adapter = self
|
||||
.settings_service
|
||||
.get_setting(
|
||||
&Self::get_settings_key(EXCHANGE_ADAPTER_SETTING_KEY),
|
||||
DEFAULT_ADAPTER_NAME,
|
||||
)
|
||||
.await;
|
||||
|
||||
if current_adapter.is_empty() {
|
||||
self.settings_service
|
||||
.update_setting(
|
||||
Self::get_settings_key(EXCHANGE_ADAPTER_SETTING_KEY),
|
||||
DEFAULT_ADAPTER_NAME.to_string(),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ExchangeRateService for ExchangeRateServiceImpl {
|
||||
async fn get_exchange_rate(
|
||||
&self,
|
||||
from_currency: &str,
|
||||
to_currency: &str,
|
||||
) -> CommandResult<Decimal> {
|
||||
match self
|
||||
.get_cached_exchange_rate(from_currency, to_currency)
|
||||
.await
|
||||
{
|
||||
Ok(Some((cached_rate, timestamp))) => {
|
||||
// If cached rate is less than 12 hours old and is same date, return it
|
||||
if chrono::Utc::now() - timestamp < chrono::Duration::hours(12)
|
||||
&& timestamp.date_naive() == chrono::Utc::now().date_naive()
|
||||
{
|
||||
return Ok(cached_rate);
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
// No cached rate, will fetch new rate
|
||||
}
|
||||
Err(e) => {
|
||||
// Log the error but continue to fetch new rate
|
||||
log::error!("Error fetching cached exchange rate: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
let adapter = self.adapter.read().await;
|
||||
let (rate, timestamp) = adapter
|
||||
.get_exchange_rate(from_currency, to_currency)
|
||||
.await?;
|
||||
|
||||
// ignore errors when storing exchange rate, since we can still return the fetched rate
|
||||
let _ = self
|
||||
.store_exchange_rate(from_currency, to_currency, rate, timestamp)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Error storing exchange rate: {}", e));
|
||||
|
||||
Ok(rate)
|
||||
}
|
||||
|
||||
async fn get_supported_currencies(&self) -> Vec<String> {
|
||||
let cache = self.supported_currencies_cache.read().await;
|
||||
if let Some((cached_currencies, timestamp)) = &*cache {
|
||||
// If cached currencies are less than 12 hours old and is same date, return them
|
||||
if *timestamp > chrono::Utc::now() - chrono::Duration::hours(12)
|
||||
&& timestamp.date_naive() == chrono::Utc::now().date_naive()
|
||||
{
|
||||
return cached_currencies.clone();
|
||||
}
|
||||
}
|
||||
drop(cache);
|
||||
|
||||
let settings = self.settings_service.get_settings(None).await;
|
||||
let base_currency = match settings {
|
||||
Ok(s) => s.base_currency,
|
||||
Err(_) => return vec![], // If we can't get settings, return empty list
|
||||
};
|
||||
|
||||
let adapter = self.adapter.read().await;
|
||||
let currencies = adapter.get_supported_currencies(Some(&base_currency)).await;
|
||||
*self.supported_currencies_cache.write().await =
|
||||
Some((currencies.clone(), chrono::Utc::now()));
|
||||
|
||||
currencies
|
||||
}
|
||||
|
||||
async fn set_adapter(&self, adapter_name: &str) -> CommandResult<()> {
|
||||
if let Some(adapter) = super::adapters::get_adapter_by_name(adapter_name) {
|
||||
*self.adapter.write().await = adapter;
|
||||
// Clear the supported currencies cache when adapter changes
|
||||
*self.supported_currencies_cache.write().await = None;
|
||||
self.settings_service
|
||||
.update_setting(
|
||||
Self::get_settings_key(EXCHANGE_ADAPTER_SETTING_KEY),
|
||||
adapter_name.to_string(),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(crate::errors::AppError::NotFound(format!(
|
||||
"Adapter '{}' not found",
|
||||
adapter_name
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_current_adapter(&self) -> CommandResult<String> {
|
||||
let settings = self
|
||||
.settings_service
|
||||
.get_setting(
|
||||
Self::get_settings_key(EXCHANGE_ADAPTER_SETTING_KEY).as_str(),
|
||||
"",
|
||||
)
|
||||
.await;
|
||||
|
||||
if settings.is_empty() {
|
||||
return Err(crate::errors::AppError::NotFound(
|
||||
"No adapter selected".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(settings)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::errors::AppError;
|
||||
use crate::services::exchange_rate::adapters::{
|
||||
ExchangeRateAdapter, get_adapter_by_name, get_adapter_info,
|
||||
};
|
||||
use chrono::NaiveDateTime;
|
||||
|
||||
// Mock adapter for testing
|
||||
struct MockAdapter {
|
||||
_name: String,
|
||||
rate_to_return: Option<(Decimal, u64)>,
|
||||
currencies_to_return: Vec<String>,
|
||||
}
|
||||
|
||||
impl MockAdapter {
|
||||
fn new(name: &str) -> Self {
|
||||
Self {
|
||||
_name: name.to_string(),
|
||||
rate_to_return: Some((
|
||||
Decimal::from_f64_retain(0.85).expect("Failed to retain f64 as Decimal"),
|
||||
1704067200,
|
||||
)), // 2024-01-01 00:00:00 UTC
|
||||
currencies_to_return: vec!["USD".to_string(), "EUR".to_string(), "GBP".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
fn with_rate(mut self, rate: Decimal, timestamp: u64) -> Self {
|
||||
self.rate_to_return = Some((rate, timestamp));
|
||||
self
|
||||
}
|
||||
|
||||
fn with_currencies(mut self, currencies: Vec<String>) -> Self {
|
||||
self.currencies_to_return = currencies;
|
||||
self
|
||||
}
|
||||
|
||||
fn with_error(mut self) -> Self {
|
||||
self.rate_to_return = None;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ExchangeRateAdapter for MockAdapter {
|
||||
fn get_info() -> ExchangeRateAdapterInfo {
|
||||
ExchangeRateAdapterInfo {
|
||||
name: "mock_adapter".to_string(),
|
||||
display_name: "Mock Adapter".to_string(),
|
||||
description: "A mock adapter for testing.".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_exchange_rate(
|
||||
&self,
|
||||
_from_currency: &str,
|
||||
_to_currency: &str,
|
||||
) -> CommandResult<(Decimal, u64)> {
|
||||
match self.rate_to_return {
|
||||
Some((rate, timestamp)) => Ok((rate, timestamp)),
|
||||
None => Err(AppError::Internal("Mock error".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_supported_currencies(&self, _base_currency: Option<&str>) -> Vec<String> {
|
||||
self.currencies_to_return.clone()
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Creating DbService with mock connections requires special handling
|
||||
// due to type constraints. The tests below use a simpler approach.
|
||||
|
||||
#[test]
|
||||
fn test_get_adapter_info() {
|
||||
let infos = get_adapter_info();
|
||||
assert_eq!(infos.len(), 2);
|
||||
assert_eq!(infos[0].name, "exchange_api");
|
||||
assert_eq!(infos[1].name, "exchange_rate_api");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_adapter_by_name_exchange_api() {
|
||||
let adapter = get_adapter_by_name("exchange_api");
|
||||
assert!(adapter.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_adapter_by_name_exchange_rate_api() {
|
||||
let adapter = get_adapter_by_name("exchange_rate_api");
|
||||
assert!(adapter.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_adapter_by_name_invalid() {
|
||||
let adapter = get_adapter_by_name("invalid_adapter");
|
||||
assert!(adapter.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mock_adapter_get_exchange_rate() {
|
||||
let adapter = MockAdapter::new("test");
|
||||
let result = adapter.get_exchange_rate("USD", "EUR").await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let (rate, timestamp) = result.expect("Result is None");
|
||||
assert_eq!(
|
||||
rate,
|
||||
Decimal::from_f64_retain(0.85).expect("Failed to retain f64 as Decimal")
|
||||
);
|
||||
assert_eq!(timestamp, 1704067200);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mock_adapter_get_supported_currencies() {
|
||||
let adapter = MockAdapter::new("test");
|
||||
let currencies = adapter.get_supported_currencies(Some("USD")).await;
|
||||
|
||||
assert_eq!(currencies.len(), 3);
|
||||
assert!(currencies.contains(&"USD".to_string()));
|
||||
assert!(currencies.contains(&"EUR".to_string()));
|
||||
assert!(currencies.contains(&"GBP".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mock_adapter_with_custom_rate() {
|
||||
let adapter = MockAdapter::new("test").with_rate(
|
||||
Decimal::from_f64_retain(1.25).expect("Failed to retain f64 as Decimal"),
|
||||
1704153600,
|
||||
);
|
||||
|
||||
let result = adapter.get_exchange_rate("USD", "CAD").await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let (rate, timestamp) = result.expect("Result is None");
|
||||
assert_eq!(
|
||||
rate,
|
||||
Decimal::from_f64_retain(1.25).expect("Failed to retain f64 as Decimal")
|
||||
);
|
||||
assert_eq!(timestamp, 1704153600);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mock_adapter_with_custom_currencies() {
|
||||
let custom_currencies = vec!["JPY".to_string(), "CNY".to_string()];
|
||||
let adapter = MockAdapter::new("test").with_currencies(custom_currencies.clone());
|
||||
|
||||
let currencies = adapter.get_supported_currencies(None).await;
|
||||
assert_eq!(currencies, custom_currencies);
|
||||
}
|
||||
|
||||
// Note: The following tests require proper DbService mocking which is complex
|
||||
// due to the Arc<DbService> type. These are integration test patterns.
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_store_exchange_rate() {
|
||||
// Create the exchange rate service with mock adapter
|
||||
let mock_adapter = Arc::new(MockAdapter::new("test"));
|
||||
|
||||
// We need to use a real DbService, so we'll test the store_exchange_rate logic indirectly
|
||||
// through get_exchange_rate when no cache exists
|
||||
|
||||
// For now, just verify the mock adapter works
|
||||
let result = mock_adapter.get_exchange_rate("USD", "EUR").await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_exchange_rate_service_get_available_adapters() {
|
||||
let mock_adapter: Arc<dyn ExchangeRateAdapter + Send + Sync> =
|
||||
Arc::new(MockAdapter::new("test"));
|
||||
|
||||
// Create service - we need to bypass the DbService type issue for this test
|
||||
// by testing the trait method directly
|
||||
let adapters = mock_adapter.get_supported_currencies(None).await;
|
||||
assert!(!adapters.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decimal_rate_parsing() {
|
||||
// Test that we can parse decimal rates correctly
|
||||
let rate_str = "0.8512345678";
|
||||
let rate = Decimal::from_str_exact(rate_str).expect("Failed to parse rate string");
|
||||
assert_eq!(rate.to_string(), rate_str);
|
||||
|
||||
// Test with larger rate
|
||||
let large_rate_str = "150.123456789012";
|
||||
let large_rate =
|
||||
Decimal::from_str_exact(large_rate_str).expect("Failed to parse large rate string");
|
||||
assert_eq!(large_rate.to_string(), large_rate_str);
|
||||
|
||||
// Test with small rate
|
||||
let small_rate_str = "0.00000001";
|
||||
let _small_rate =
|
||||
Decimal::from_str_exact(small_rate_str).expect("Failed to parse small rate string");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_timestamp_conversion() {
|
||||
// Test timestamp to DateTime conversion
|
||||
let timestamp: i64 = 1704067200; // 2024-01-01 00:00:00 UTC
|
||||
let dt = chrono::DateTime::from_timestamp(timestamp, 0);
|
||||
assert!(dt.is_some());
|
||||
|
||||
let dt = dt.expect("DateTime conversion failed");
|
||||
assert_eq!(dt.timestamp(), timestamp);
|
||||
|
||||
// Test conversion to naive UTC
|
||||
let naive = dt.naive_utc();
|
||||
assert_eq!(naive.and_utc().timestamp(), timestamp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_naive_date_time_to_utc() {
|
||||
let naive = NaiveDateTime::parse_from_str("2024-01-01 12:00:00", "%Y-%m-%d %H:%M:%S")
|
||||
.expect("Failed to parse datetime");
|
||||
|
||||
let utc: chrono::DateTime<Utc> =
|
||||
chrono::DateTime::from_naive_utc_and_offset(naive, chrono::Utc);
|
||||
assert_eq!(utc.naive_utc(), naive);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_service_trait_implementation() {
|
||||
// Verify that ExchangeRateServiceImpl implements ServiceTrait
|
||||
fn _check_service_trait<T: ServiceTrait>() {}
|
||||
|
||||
// This is a compile-time check
|
||||
// If ExchangeRateServiceImpl doesn't implement ServiceTrait, this won't compile
|
||||
// We can't call it directly due to DbService, but the type check works
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mock_adapter_error_handling() {
|
||||
let adapter = MockAdapter::new("test").with_error();
|
||||
|
||||
let result = adapter.get_exchange_rate("USD", "XXX").await;
|
||||
assert!(result.is_err());
|
||||
|
||||
match result {
|
||||
Err(AppError::Internal(msg)) => assert_eq!(msg, "Mock error"),
|
||||
_ => unreachable!("Expected AppError::Internal"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_adapter_name_cases() {
|
||||
// Test that adapter names are consistent
|
||||
let names = get_adapter_info()
|
||||
.iter()
|
||||
.map(|info| info.name.clone())
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
for name in &names {
|
||||
let adapter = get_adapter_by_name(name);
|
||||
assert!(adapter.is_some(), "Adapter '{}' should exist", name);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_supported_currencies_cache() {
|
||||
// Test that currencies can be retrieved
|
||||
let adapter =
|
||||
MockAdapter::new("test").with_currencies(vec!["USD".to_string(), "EUR".to_string()]);
|
||||
|
||||
let currencies = adapter.get_supported_currencies(Some("USD")).await;
|
||||
assert_eq!(currencies.len(), 2);
|
||||
|
||||
// Test with None base currency
|
||||
let currencies_none = adapter.get_supported_currencies(None).await;
|
||||
assert_eq!(currencies_none.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exchange_rate_precision() {
|
||||
// Verify decimal precision for financial calculations
|
||||
let rate1 =
|
||||
Decimal::from_f64_retain(0.1234567890).expect("Failed to retain f64 as Decimal");
|
||||
let rate2 =
|
||||
Decimal::from_f64_retain(0.9876543210).expect("Failed to retain f64 as Decimal");
|
||||
|
||||
let product = rate1 * rate2;
|
||||
// Decimal should maintain precision
|
||||
assert!(product > Decimal::ZERO);
|
||||
|
||||
// Test string conversion round-trip
|
||||
let rate_str = rate1.to_string();
|
||||
let _rate_parsed = Decimal::from_str_exact(&rate_str).expect("Failed to parse rate string");
|
||||
}
|
||||
}
|
||||
49
src-tauri/src/services/mod.rs
Normal file
49
src-tauri/src/services/mod.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
2
src-tauri/src/services/settings/mod.rs
Normal file
2
src-tauri/src/services/settings/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod service;
|
||||
pub mod types;
|
||||
635
src-tauri/src/services/settings/service.rs
Normal file
635
src-tauri/src/services/settings/service.rs
Normal file
@@ -0,0 +1,635 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use log::warn;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter,
|
||||
};
|
||||
use struct_iterable::Iterable;
|
||||
|
||||
use crate::{
|
||||
db::{connection::ConnectionSource, entities::settings},
|
||||
errors::CommandResult,
|
||||
services::{
|
||||
ServiceTrait,
|
||||
settings::types::settings::{Settings, UpdateSettingsInput},
|
||||
},
|
||||
};
|
||||
|
||||
pub type SettingModel = settings::Model;
|
||||
|
||||
#[async_trait]
|
||||
pub trait SettingsService: Send + Sync {
|
||||
async fn get_settings(&self, tx: Option<&ConnectionSource<'_>>) -> CommandResult<Settings>;
|
||||
async fn get_setting_with_prefix(&self, prefix: &str) -> HashMap<String, String>;
|
||||
async fn get_setting(&self, key: &str, default: &str) -> String;
|
||||
async fn update_setting(
|
||||
&self,
|
||||
key: String,
|
||||
value: String,
|
||||
tx: Option<&ConnectionSource<'_>>,
|
||||
) -> CommandResult<()>;
|
||||
async fn update_settings(
|
||||
&self,
|
||||
input: UpdateSettingsInput,
|
||||
tx: Option<&ConnectionSource<'_>>,
|
||||
) -> CommandResult<()>;
|
||||
async fn initialize_default_settings(
|
||||
&self,
|
||||
tx: Option<&ConnectionSource<'_>>,
|
||||
) -> CommandResult<()>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ServiceTrait for dyn SettingsService {
|
||||
async fn on_app_start(&self) -> CommandResult<()> {
|
||||
self.initialize_default_settings(None).await
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SettingsServiceImpl {
|
||||
db: DatabaseConnection,
|
||||
}
|
||||
|
||||
impl SettingsServiceImpl {
|
||||
pub fn new(db: DatabaseConnection) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SettingsService for SettingsServiceImpl {
|
||||
async fn get_settings(&self, tx: Option<&ConnectionSource<'_>>) -> CommandResult<Settings> {
|
||||
let settings_list = crate::db::entities::settings::Entity::find()
|
||||
.all(&tx.unwrap_or(&ConnectionSource::Connection(&self.db)))
|
||||
.await?;
|
||||
|
||||
let mut map = HashMap::new();
|
||||
for setting in settings_list {
|
||||
map.insert(setting.key, setting.value.unwrap_or_default());
|
||||
}
|
||||
|
||||
Ok(Settings::from(map))
|
||||
}
|
||||
|
||||
async fn get_setting_with_prefix(&self, prefix: &str) -> HashMap<String, String> {
|
||||
let settings_list = crate::db::entities::settings::Entity::find()
|
||||
.filter(settings::Column::Key.starts_with(prefix.to_string()))
|
||||
.all(&self.db)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut map = HashMap::new();
|
||||
for setting in settings_list {
|
||||
map.insert(setting.key, setting.value.unwrap_or_default());
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
async fn get_setting(&self, key: &str, default: &str) -> String {
|
||||
crate::db::entities::settings::Entity::find_by_id(key.to_string())
|
||||
.one(&self.db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|s| s.value)
|
||||
.unwrap_or_else(|| default.to_string())
|
||||
}
|
||||
|
||||
async fn update_setting(
|
||||
&self,
|
||||
key: String,
|
||||
value: String,
|
||||
tx: Option<&ConnectionSource<'_>>,
|
||||
) -> CommandResult<()> {
|
||||
let now = Utc::now().naive_utc();
|
||||
let tx_ = match tx {
|
||||
Some(tx) => tx,
|
||||
_ => &ConnectionSource::Connection(&self.db),
|
||||
};
|
||||
|
||||
let existing = crate::db::entities::settings::Entity::find_by_id(key.clone())
|
||||
.one(&tx_)
|
||||
.await?;
|
||||
|
||||
if let Some(setting) = existing {
|
||||
let mut active_model: settings::ActiveModel = setting.into();
|
||||
active_model.value = Set(Some(value));
|
||||
active_model.updated_at = Set(now);
|
||||
active_model.update(&tx_).await?;
|
||||
} else {
|
||||
let new_setting = settings::ActiveModel {
|
||||
key: Set(key),
|
||||
value: Set(Some(value)),
|
||||
updated_at: Set(now),
|
||||
};
|
||||
new_setting.insert(&tx_).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_settings(
|
||||
&self,
|
||||
input: UpdateSettingsInput,
|
||||
tx: Option<&ConnectionSource<'_>>,
|
||||
) -> CommandResult<()> {
|
||||
let tx_ = match tx {
|
||||
Some(tx) => tx,
|
||||
_ => &ConnectionSource::Connection(&self.db),
|
||||
};
|
||||
|
||||
for (key, value) in input.settings {
|
||||
self.update_setting(key, value, Some(tx_)).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn initialize_default_settings(
|
||||
&self,
|
||||
tx: Option<&ConnectionSource<'_>>,
|
||||
) -> CommandResult<()> {
|
||||
let default_settings = Settings::default();
|
||||
let settings_map: HashMap<String, String> = default_settings
|
||||
.iter()
|
||||
.filter_map(|(k, v)| -> Option<(String, String)> {
|
||||
let value_string = if let Some(s) = v.downcast_ref::<String>() {
|
||||
s.clone()
|
||||
} else if let Some(i) = v.downcast_ref::<i32>() {
|
||||
i.to_string()
|
||||
} else if let Some(e) =
|
||||
v.downcast_ref::<crate::services::settings::types::language::Language>()
|
||||
{
|
||||
e.to_string()
|
||||
} else if let Some(e) =
|
||||
v.downcast_ref::<crate::services::settings::types::view::View>()
|
||||
{
|
||||
e.to_string()
|
||||
} else if let Some(e) =
|
||||
v.downcast_ref::<crate::services::settings::types::display_mode::DisplayMode>()
|
||||
{
|
||||
e.to_string()
|
||||
} else if let Some(e) =
|
||||
v.downcast_ref::<crate::services::settings::types::theme::Theme>()
|
||||
{
|
||||
e.to_string()
|
||||
} else if let Some(e) =
|
||||
v.downcast_ref::<crate::services::settings::types::date_of_week::DateOfWeek>()
|
||||
{
|
||||
e.to_string()
|
||||
} else {
|
||||
warn!("Unsupported setting type for key '{k}'");
|
||||
return None;
|
||||
};
|
||||
Some((k.to_string(), value_string))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let tx_ = match tx {
|
||||
Some(tx) => tx,
|
||||
_ => &ConnectionSource::Connection(&self.db),
|
||||
};
|
||||
|
||||
for (key, value) in settings_map {
|
||||
let existing = crate::db::entities::settings::Entity::find_by_id(key.clone())
|
||||
.one(&tx_)
|
||||
.await?;
|
||||
if existing.is_none() {
|
||||
self.update_setting(key.clone(), value.clone(), Some(tx_))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::NaiveDateTime;
|
||||
use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult};
|
||||
|
||||
fn create_mock_setting(key: &str, value: &str) -> settings::Model {
|
||||
settings::Model {
|
||||
key: key.to_string(),
|
||||
value: Some(value.to_string()),
|
||||
updated_at: NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
|
||||
.expect("Failed to parse datetime"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_settings_empty() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![] as Vec<settings::Model>])
|
||||
.into_connection();
|
||||
|
||||
let service = SettingsServiceImpl::new(db);
|
||||
let result = service.get_settings(None).await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let settings = result.expect("Failed to get settings");
|
||||
// Should return default settings when no settings exist
|
||||
assert_eq!(settings.language.to_string(), "en");
|
||||
assert_eq!(settings.default_currency, "HKD");
|
||||
assert_eq!(settings.base_currency, "HKD");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_settings_with_values() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![
|
||||
create_mock_setting("language", "zh-TW"),
|
||||
create_mock_setting("default_currency", "USD"),
|
||||
create_mock_setting("base_currency", "EUR"),
|
||||
create_mock_setting("timezone", "Asia/Tokyo"),
|
||||
] as Vec<settings::Model>])
|
||||
.into_connection();
|
||||
|
||||
let service = SettingsServiceImpl::new(db);
|
||||
let result = service.get_settings(None).await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let settings = result.expect("Failed to get settings");
|
||||
assert_eq!(settings.language.to_string(), "zh-TW");
|
||||
assert_eq!(settings.default_currency, "USD");
|
||||
assert_eq!(settings.base_currency, "EUR");
|
||||
assert_eq!(settings.timezone, "Asia/Tokyo");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_setting_insert_new() {
|
||||
// Mock: find_by_id returns None (setting doesn't exist)
|
||||
// Insert returns the inserted model via RETURNING clause
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![] as Vec<settings::Model>]) // find_by_id
|
||||
.append_query_results(vec![
|
||||
vec![create_mock_setting("language", "zh-HK")] as Vec<settings::Model>
|
||||
]) // insert returning
|
||||
.append_exec_results(vec![MockExecResult {
|
||||
last_insert_id: 1,
|
||||
rows_affected: 1,
|
||||
}])
|
||||
.into_connection();
|
||||
|
||||
let service = SettingsServiceImpl::new(db);
|
||||
let result = service
|
||||
.update_setting("language".to_string(), "zh-HK".to_string(), None)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_setting_update_existing() {
|
||||
// Mock: find_by_id returns existing setting
|
||||
// Update returns the updated model via RETURNING clause
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![
|
||||
vec![create_mock_setting("language", "en")] as Vec<settings::Model>
|
||||
]) // find_by_id
|
||||
.append_query_results(vec![
|
||||
vec![create_mock_setting("language", "zh-TW")] as Vec<settings::Model>
|
||||
]) // update returning
|
||||
.append_exec_results(vec![MockExecResult {
|
||||
last_insert_id: 0,
|
||||
rows_affected: 1,
|
||||
}])
|
||||
.into_connection();
|
||||
|
||||
let service = SettingsServiceImpl::new(db);
|
||||
let result = service
|
||||
.update_setting("language".to_string(), "zh-TW".to_string(), None)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_settings_multiple() {
|
||||
// Mock two update operations
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
// First update: language - find returns None, then insert
|
||||
.append_query_results(vec![vec![] as Vec<settings::Model>])
|
||||
.append_query_results(vec![
|
||||
vec![create_mock_setting("language", "zh-TW")] as Vec<settings::Model>
|
||||
])
|
||||
.append_exec_results(vec![MockExecResult {
|
||||
last_insert_id: 1,
|
||||
rows_affected: 1,
|
||||
}])
|
||||
// Second update: theme - find returns None, then insert
|
||||
.append_query_results(vec![vec![] as Vec<settings::Model>])
|
||||
.append_query_results(vec![
|
||||
vec![create_mock_setting("theme", "dark")] as Vec<settings::Model>
|
||||
])
|
||||
.append_exec_results(vec![MockExecResult {
|
||||
last_insert_id: 2,
|
||||
rows_affected: 1,
|
||||
}])
|
||||
.into_connection();
|
||||
|
||||
let service = SettingsServiceImpl::new(db);
|
||||
let input = UpdateSettingsInput {
|
||||
settings: {
|
||||
let mut map = HashMap::new();
|
||||
map.insert("language".to_string(), "zh-TW".to_string());
|
||||
map.insert("theme".to_string(), "dark".to_string());
|
||||
map
|
||||
},
|
||||
};
|
||||
let result = service.update_settings(input, None).await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_initialize_default_settings() {
|
||||
// Settings has 12 fields, we mock: 1 exists + 11 inserts
|
||||
// For each insert: initialize_default_settings does find_by_id, then update_setting does (find_by_id + insert)
|
||||
// So each insert needs 2 find queries + 1 insert returning query + 1 exec
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
// Check language (exists) - only find, no insert
|
||||
.append_query_results(vec![
|
||||
vec![create_mock_setting("language", "en")] as Vec<settings::Model>
|
||||
])
|
||||
// Check default_currency (not exists) -> update_setting -> find + insert
|
||||
.append_query_results(vec![vec![] as Vec<settings::Model>]) // initialize_default_settings find
|
||||
.append_query_results(vec![vec![] as Vec<settings::Model>]) // update_setting find
|
||||
.append_query_results(vec![
|
||||
vec![create_mock_setting("default_currency", "HKD")] as Vec<settings::Model>
|
||||
]) // insert returning
|
||||
.append_exec_results(vec![MockExecResult {
|
||||
last_insert_id: 1,
|
||||
rows_affected: 1,
|
||||
}])
|
||||
// Check base_currency (not exists) -> insert
|
||||
.append_query_results(vec![vec![] as Vec<settings::Model>])
|
||||
.append_query_results(vec![vec![] as Vec<settings::Model>])
|
||||
.append_query_results(vec![
|
||||
vec![create_mock_setting("base_currency", "HKD")] as Vec<settings::Model>
|
||||
])
|
||||
.append_exec_results(vec![MockExecResult {
|
||||
last_insert_id: 2,
|
||||
rows_affected: 1,
|
||||
}])
|
||||
// Check timezone (not exists) -> insert
|
||||
.append_query_results(vec![vec![] as Vec<settings::Model>])
|
||||
.append_query_results(vec![vec![] as Vec<settings::Model>])
|
||||
.append_query_results(vec![
|
||||
vec![create_mock_setting("timezone", "auto")] as Vec<settings::Model>
|
||||
])
|
||||
.append_exec_results(vec![MockExecResult {
|
||||
last_insert_id: 3,
|
||||
rows_affected: 1,
|
||||
}])
|
||||
// Check default_view (not exists) -> insert
|
||||
.append_query_results(vec![vec![] as Vec<settings::Model>])
|
||||
.append_query_results(vec![vec![] as Vec<settings::Model>])
|
||||
.append_query_results(vec![
|
||||
vec![create_mock_setting("default_view", "combined")] as Vec<settings::Model>
|
||||
])
|
||||
.append_exec_results(vec![MockExecResult {
|
||||
last_insert_id: 4,
|
||||
rows_affected: 1,
|
||||
}])
|
||||
// Check display_mode (not exists) -> insert
|
||||
.append_query_results(vec![vec![] as Vec<settings::Model>])
|
||||
.append_query_results(vec![vec![] as Vec<settings::Model>])
|
||||
.append_query_results(vec![
|
||||
vec![create_mock_setting("display_mode", "system")] as Vec<settings::Model>
|
||||
])
|
||||
.append_exec_results(vec![MockExecResult {
|
||||
last_insert_id: 5,
|
||||
rows_affected: 1,
|
||||
}])
|
||||
// Check decimal_places (not exists) -> insert
|
||||
.append_query_results(vec![vec![] as Vec<settings::Model>])
|
||||
.append_query_results(vec![vec![] as Vec<settings::Model>])
|
||||
.append_query_results(vec![
|
||||
vec![create_mock_setting("decimal_places", "2")] as Vec<settings::Model>
|
||||
])
|
||||
.append_exec_results(vec![MockExecResult {
|
||||
last_insert_id: 6,
|
||||
rows_affected: 1,
|
||||
}])
|
||||
// Check date_format (not exists) -> insert
|
||||
.append_query_results(vec![vec![] as Vec<settings::Model>])
|
||||
.append_query_results(vec![vec![] as Vec<settings::Model>])
|
||||
.append_query_results(vec![
|
||||
vec![create_mock_setting("date_format", "YYYY-MM-DD")] as Vec<settings::Model>
|
||||
])
|
||||
.append_exec_results(vec![MockExecResult {
|
||||
last_insert_id: 7,
|
||||
rows_affected: 1,
|
||||
}])
|
||||
// Check time_format (not exists) -> insert
|
||||
.append_query_results(vec![vec![] as Vec<settings::Model>])
|
||||
.append_query_results(vec![vec![] as Vec<settings::Model>])
|
||||
.append_query_results(vec![
|
||||
vec![create_mock_setting("time_format", "24h")] as Vec<settings::Model>
|
||||
])
|
||||
.append_exec_results(vec![MockExecResult {
|
||||
last_insert_id: 8,
|
||||
rows_affected: 1,
|
||||
}])
|
||||
// Check theme (not exists) -> insert
|
||||
.append_query_results(vec![vec![] as Vec<settings::Model>])
|
||||
.append_query_results(vec![vec![] as Vec<settings::Model>])
|
||||
.append_query_results(vec![
|
||||
vec![create_mock_setting("theme", "system")] as Vec<settings::Model>
|
||||
])
|
||||
.append_exec_results(vec![MockExecResult {
|
||||
last_insert_id: 9,
|
||||
rows_affected: 1,
|
||||
}])
|
||||
// Check week_starts_on (not exists) -> insert
|
||||
.append_query_results(vec![vec![] as Vec<settings::Model>])
|
||||
.append_query_results(vec![vec![] as Vec<settings::Model>])
|
||||
.append_query_results(vec![
|
||||
vec![create_mock_setting("week_starts_on", "sunday")] as Vec<settings::Model>
|
||||
])
|
||||
.append_exec_results(vec![MockExecResult {
|
||||
last_insert_id: 10,
|
||||
rows_affected: 1,
|
||||
}])
|
||||
// Check scheduled_check_interval (not exists) -> insert
|
||||
.append_query_results(vec![vec![] as Vec<settings::Model>])
|
||||
.append_query_results(vec![vec![] as Vec<settings::Model>])
|
||||
.append_query_results(vec![
|
||||
vec![create_mock_setting("scheduled_check_interval", "1")] as Vec<settings::Model>,
|
||||
])
|
||||
.append_exec_results(vec![MockExecResult {
|
||||
last_insert_id: 11,
|
||||
rows_affected: 1,
|
||||
}])
|
||||
.into_connection();
|
||||
|
||||
let service = SettingsServiceImpl::new(db);
|
||||
let result = service.initialize_default_settings(None).await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_initialize_default_settings_all_exist() {
|
||||
// Mock scenario where all default settings already exist
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![
|
||||
vec![create_mock_setting("language", "en")] as Vec<settings::Model>
|
||||
])
|
||||
.append_query_results(vec![
|
||||
vec![create_mock_setting("default_currency", "HKD")] as Vec<settings::Model>
|
||||
])
|
||||
.append_query_results(vec![
|
||||
vec![create_mock_setting("base_currency", "HKD")] as Vec<settings::Model>
|
||||
])
|
||||
.append_query_results(vec![
|
||||
vec![create_mock_setting("timezone", "auto")] as Vec<settings::Model>
|
||||
])
|
||||
.append_query_results(vec![
|
||||
vec![create_mock_setting("default_view", "combined")] as Vec<settings::Model>
|
||||
])
|
||||
.append_query_results(vec![
|
||||
vec![create_mock_setting("display_mode", "system")] as Vec<settings::Model>
|
||||
])
|
||||
.append_query_results(vec![
|
||||
vec![create_mock_setting("decimal_places", "2")] as Vec<settings::Model>
|
||||
])
|
||||
.append_query_results(vec![
|
||||
vec![create_mock_setting("date_format", "YYYY-MM-DD")] as Vec<settings::Model>
|
||||
])
|
||||
.append_query_results(vec![
|
||||
vec![create_mock_setting("time_format", "24h")] as Vec<settings::Model>
|
||||
])
|
||||
.append_query_results(vec![
|
||||
vec![create_mock_setting("theme", "system")] as Vec<settings::Model>
|
||||
])
|
||||
.append_query_results(vec![
|
||||
vec![create_mock_setting("week_starts_on", "sunday")] as Vec<settings::Model>
|
||||
])
|
||||
.append_query_results(vec![
|
||||
vec![create_mock_setting("scheduled_check_interval", "1")] as Vec<settings::Model>,
|
||||
])
|
||||
.into_connection();
|
||||
|
||||
let service = SettingsServiceImpl::new(db);
|
||||
let result = service.initialize_default_settings(None).await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_setting_with_default() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![] as Vec<settings::Model>])
|
||||
.into_connection();
|
||||
|
||||
let service = SettingsServiceImpl::new(db);
|
||||
let result = service
|
||||
.get_setting("nonexistent_key", "default_value")
|
||||
.await;
|
||||
|
||||
assert_eq!(result, "default_value");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_setting_with_value() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![
|
||||
vec![create_mock_setting("existing_key", "stored_value")] as Vec<settings::Model>,
|
||||
])
|
||||
.into_connection();
|
||||
|
||||
let service = SettingsServiceImpl::new(db);
|
||||
let result = service.get_setting("existing_key", "default_value").await;
|
||||
|
||||
assert_eq!(result, "stored_value");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_settings_with_all_fields() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![
|
||||
create_mock_setting("language", "zh-HK"),
|
||||
create_mock_setting("default_currency", "USD"),
|
||||
create_mock_setting("base_currency", "EUR"),
|
||||
create_mock_setting("timezone", "UTC"),
|
||||
create_mock_setting("default_view", "split"),
|
||||
create_mock_setting("display_mode", "dark"),
|
||||
create_mock_setting("decimal_places", "4"),
|
||||
create_mock_setting("date_format", "DD/MM/YYYY"),
|
||||
create_mock_setting("time_format", "12h"),
|
||||
create_mock_setting("theme", "light"),
|
||||
create_mock_setting("week_starts_on", "monday"),
|
||||
create_mock_setting("scheduled_check_interval", "7"),
|
||||
] as Vec<settings::Model>])
|
||||
.into_connection();
|
||||
|
||||
let service = SettingsServiceImpl::new(db);
|
||||
let result = service.get_settings(None).await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let settings = result.expect("Failed to get settings");
|
||||
assert_eq!(settings.language.to_string(), "zh-HK");
|
||||
assert_eq!(settings.default_currency, "USD");
|
||||
assert_eq!(settings.base_currency, "EUR");
|
||||
assert_eq!(settings.timezone, "UTC");
|
||||
assert_eq!(settings.default_view.to_string(), "split");
|
||||
assert_eq!(settings.display_mode.to_string(), "dark");
|
||||
assert_eq!(settings.decimal_places, 4);
|
||||
assert_eq!(settings.date_format, "DD/MM/YYYY");
|
||||
assert_eq!(settings.time_format, "12h");
|
||||
assert_eq!(settings.theme.to_string(), "light");
|
||||
assert_eq!(settings.week_starts_on.to_string(), "monday");
|
||||
assert_eq!(settings.scheduled_check_interval, 7);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_setting_with_null_value_in_db() {
|
||||
let setting_with_null = settings::Model {
|
||||
key: "test_key".to_string(),
|
||||
value: None,
|
||||
updated_at: NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
|
||||
.expect("Failed to parse datetime"),
|
||||
};
|
||||
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![setting_with_null] as Vec<settings::Model>]) // find_by_id
|
||||
.append_query_results(vec![
|
||||
vec![create_mock_setting("test_key", "new_value")] as Vec<settings::Model>
|
||||
]) // update returning
|
||||
.append_exec_results(vec![MockExecResult {
|
||||
last_insert_id: 0,
|
||||
rows_affected: 1,
|
||||
}])
|
||||
.into_connection();
|
||||
|
||||
let service = SettingsServiceImpl::new(db);
|
||||
let result = service
|
||||
.update_setting("test_key".to_string(), "new_value".to_string(), None)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_settings_transaction_logs() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![] as Vec<settings::Model>])
|
||||
.append_exec_results(vec![MockExecResult {
|
||||
last_insert_id: 0,
|
||||
rows_affected: 1,
|
||||
}])
|
||||
.into_connection();
|
||||
|
||||
let service = SettingsServiceImpl::new(db);
|
||||
|
||||
// Verify that the database backend is SQLite
|
||||
assert_eq!(service.db.get_database_backend(), DatabaseBackend::Sqlite);
|
||||
}
|
||||
}
|
||||
83
src-tauri/src/services/settings/types/date_of_week.rs
Normal file
83
src-tauri/src/services/settings/types/date_of_week.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
use serde::Serialize;
|
||||
|
||||
const SUNDAY: &str = "sunday";
|
||||
const MONDAY: &str = "monday";
|
||||
const TUESDAY: &str = "tuesday";
|
||||
const WEDNESDAY: &str = "wednesday";
|
||||
const THURSDAY: &str = "thursday";
|
||||
const FRIDAY: &str = "friday";
|
||||
const SATURDAY: &str = "saturday";
|
||||
|
||||
#[derive(Debug, Serialize, Default)]
|
||||
pub enum DateOfWeek {
|
||||
#[default]
|
||||
Sunday,
|
||||
Monday,
|
||||
Tuesday,
|
||||
Wednesday,
|
||||
Thursday,
|
||||
Friday,
|
||||
Saturday,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DateOfWeek {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
DateOfWeek::Sunday => write!(f, "{}", SUNDAY),
|
||||
DateOfWeek::Monday => write!(f, "{}", MONDAY),
|
||||
DateOfWeek::Tuesday => write!(f, "{}", TUESDAY),
|
||||
DateOfWeek::Wednesday => write!(f, "{}", WEDNESDAY),
|
||||
DateOfWeek::Thursday => write!(f, "{}", THURSDAY),
|
||||
DateOfWeek::Friday => write!(f, "{}", FRIDAY),
|
||||
DateOfWeek::Saturday => write!(f, "{}", SATURDAY),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Option<String>> for DateOfWeek {
|
||||
fn from(s: Option<String>) -> Self {
|
||||
match s {
|
||||
Some(s) => match s.as_str() {
|
||||
SUNDAY => DateOfWeek::Sunday,
|
||||
MONDAY => DateOfWeek::Monday,
|
||||
TUESDAY => DateOfWeek::Tuesday,
|
||||
WEDNESDAY => DateOfWeek::Wednesday,
|
||||
THURSDAY => DateOfWeek::Thursday,
|
||||
FRIDAY => DateOfWeek::Friday,
|
||||
SATURDAY => DateOfWeek::Saturday,
|
||||
_ => DateOfWeek::default(),
|
||||
},
|
||||
None => DateOfWeek::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for DateOfWeek {
|
||||
fn from(s: String) -> Self {
|
||||
DateOfWeek::from(Some(s))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Option<i32>> for DateOfWeek {
|
||||
fn from(i: Option<i32>) -> Self {
|
||||
match i {
|
||||
Some(i) => match i {
|
||||
0 => DateOfWeek::Sunday,
|
||||
1 => DateOfWeek::Monday,
|
||||
2 => DateOfWeek::Tuesday,
|
||||
3 => DateOfWeek::Wednesday,
|
||||
4 => DateOfWeek::Thursday,
|
||||
5 => DateOfWeek::Friday,
|
||||
6 => DateOfWeek::Saturday,
|
||||
_ => DateOfWeek::default(),
|
||||
},
|
||||
None => DateOfWeek::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i32> for DateOfWeek {
|
||||
fn from(i: i32) -> Self {
|
||||
DateOfWeek::from(Some(i))
|
||||
}
|
||||
}
|
||||
43
src-tauri/src/services/settings/types/display_mode.rs
Normal file
43
src-tauri/src/services/settings/types/display_mode.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use serde::Serialize;
|
||||
|
||||
const SYSTEM: &str = "system";
|
||||
const LIGHT: &str = "light";
|
||||
const DARK: &str = "dark";
|
||||
|
||||
#[derive(Debug, Serialize, Default)]
|
||||
pub enum DisplayMode {
|
||||
#[default]
|
||||
System,
|
||||
Light,
|
||||
Dark,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DisplayMode {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
DisplayMode::System => write!(f, "{}", SYSTEM),
|
||||
DisplayMode::Light => write!(f, "{}", LIGHT),
|
||||
DisplayMode::Dark => write!(f, "{}", DARK),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Option<String>> for DisplayMode {
|
||||
fn from(s: Option<String>) -> Self {
|
||||
match s {
|
||||
Some(s) => match s.as_str() {
|
||||
SYSTEM => DisplayMode::System,
|
||||
LIGHT => DisplayMode::Light,
|
||||
DARK => DisplayMode::Dark,
|
||||
_ => DisplayMode::default(),
|
||||
},
|
||||
None => DisplayMode::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for DisplayMode {
|
||||
fn from(s: String) -> Self {
|
||||
DisplayMode::from(Some(s))
|
||||
}
|
||||
}
|
||||
43
src-tauri/src/services/settings/types/language.rs
Normal file
43
src-tauri/src/services/settings/types/language.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use serde::Serialize;
|
||||
|
||||
const ENGLISH: &str = "en";
|
||||
const TRADITIONAL_CHINESE: &str = "zh-TW";
|
||||
const CANTONESE: &str = "zh-HK";
|
||||
|
||||
#[derive(Debug, Serialize, Default)]
|
||||
pub enum Language {
|
||||
#[default]
|
||||
English,
|
||||
TraditionalChinese,
|
||||
Cantonese,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Language {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Language::English => write!(f, "{}", ENGLISH),
|
||||
Language::TraditionalChinese => write!(f, "{}", TRADITIONAL_CHINESE),
|
||||
Language::Cantonese => write!(f, "{}", CANTONESE),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Option<String>> for Language {
|
||||
fn from(s: Option<String>) -> Self {
|
||||
match s {
|
||||
Some(s) => match s.as_str() {
|
||||
ENGLISH => Language::English,
|
||||
TRADITIONAL_CHINESE => Language::TraditionalChinese,
|
||||
CANTONESE => Language::Cantonese,
|
||||
_ => Language::default(),
|
||||
},
|
||||
None => Language::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for Language {
|
||||
fn from(s: String) -> Self {
|
||||
Language::from(Some(s))
|
||||
}
|
||||
}
|
||||
6
src-tauri/src/services/settings/types/mod.rs
Normal file
6
src-tauri/src/services/settings/types/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod date_of_week;
|
||||
pub mod display_mode;
|
||||
pub mod language;
|
||||
pub mod settings;
|
||||
pub mod theme;
|
||||
pub mod view;
|
||||
107
src-tauri/src/services/settings/types/settings.rs
Normal file
107
src-tauri/src/services/settings/types/settings.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use struct_iterable::Iterable;
|
||||
|
||||
use crate::services::settings::types::date_of_week::DateOfWeek;
|
||||
|
||||
use super::{display_mode::DisplayMode, language::Language, theme::Theme, view::View};
|
||||
|
||||
const LANGUAGE_KEY: &str = "language";
|
||||
const DEFAULT_CURRENCY_KEY: &str = "default_currency";
|
||||
const BASE_CURRENCY_KEY: &str = "base_currency";
|
||||
const TIMEZONE_KEY: &str = "timezone";
|
||||
const DEFAULT_VIEW_KEY: &str = "default_view";
|
||||
const DISPLAY_MODE_KEY: &str = "display_mode";
|
||||
const DECIMAL_PLACES_KEY: &str = "decimal_places";
|
||||
const DATE_FORMAT_KEY: &str = "date_format";
|
||||
const TIME_FORMAT_KEY: &str = "time_format";
|
||||
const THEME_KEY: &str = "theme";
|
||||
const WEEK_STARTS_ON_KEY: &str = "week_starts_on";
|
||||
const SCHEDULED_CHECK_INTERVAL_KEY: &str = "scheduled_check_interval";
|
||||
|
||||
const DEFAULT_DECIMAL_PLACES: i32 = 2;
|
||||
const DEFAULT_SCHEDULED_CHECK_INTERVAL: i32 = 1;
|
||||
|
||||
#[derive(Debug, Serialize, Iterable)]
|
||||
pub struct Settings {
|
||||
pub language: Language,
|
||||
pub default_currency: String,
|
||||
pub base_currency: String,
|
||||
pub timezone: String,
|
||||
pub default_view: View,
|
||||
pub display_mode: DisplayMode,
|
||||
pub decimal_places: i32,
|
||||
pub date_format: String,
|
||||
pub time_format: String,
|
||||
pub theme: Theme,
|
||||
pub week_starts_on: DateOfWeek,
|
||||
pub scheduled_check_interval: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateSettingsInput {
|
||||
pub settings: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Settings {
|
||||
language: Language::default(),
|
||||
default_currency: "HKD".to_string(),
|
||||
base_currency: "HKD".to_string(),
|
||||
timezone: "auto".to_string(),
|
||||
default_view: View::default(),
|
||||
display_mode: DisplayMode::default(),
|
||||
decimal_places: DEFAULT_DECIMAL_PLACES,
|
||||
date_format: "YYYY-MM-DD".to_string(),
|
||||
time_format: "24h".to_string(),
|
||||
theme: Theme::default(),
|
||||
week_starts_on: DateOfWeek::default(),
|
||||
scheduled_check_interval: DEFAULT_SCHEDULED_CHECK_INTERVAL,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::collections::HashMap<String, String>> for Settings {
|
||||
fn from(map: std::collections::HashMap<String, String>) -> Self {
|
||||
let default_settings = Settings::default();
|
||||
Settings {
|
||||
language: map.get(LANGUAGE_KEY).cloned().into(),
|
||||
default_currency: map
|
||||
.get(DEFAULT_CURRENCY_KEY)
|
||||
.cloned()
|
||||
.unwrap_or(default_settings.default_currency),
|
||||
base_currency: map
|
||||
.get(BASE_CURRENCY_KEY)
|
||||
.cloned()
|
||||
.unwrap_or(default_settings.base_currency),
|
||||
timezone: map
|
||||
.get(TIMEZONE_KEY)
|
||||
.cloned()
|
||||
.unwrap_or(default_settings.timezone),
|
||||
default_view: map.get(DEFAULT_VIEW_KEY).cloned().into(),
|
||||
display_mode: map.get(DISPLAY_MODE_KEY).cloned().into(),
|
||||
decimal_places: map
|
||||
.get(DECIMAL_PLACES_KEY)
|
||||
.cloned()
|
||||
.unwrap_or(default_settings.decimal_places.to_string())
|
||||
.parse()
|
||||
.unwrap_or(DEFAULT_DECIMAL_PLACES),
|
||||
date_format: map
|
||||
.get(DATE_FORMAT_KEY)
|
||||
.cloned()
|
||||
.unwrap_or(default_settings.date_format),
|
||||
time_format: map
|
||||
.get(TIME_FORMAT_KEY)
|
||||
.cloned()
|
||||
.unwrap_or(default_settings.time_format),
|
||||
theme: map.get(THEME_KEY).cloned().into(),
|
||||
week_starts_on: map.get(WEEK_STARTS_ON_KEY).cloned().into(),
|
||||
scheduled_check_interval: map
|
||||
.get(SCHEDULED_CHECK_INTERVAL_KEY)
|
||||
.cloned()
|
||||
.unwrap_or(default_settings.scheduled_check_interval.to_string())
|
||||
.parse()
|
||||
.unwrap_or(DEFAULT_SCHEDULED_CHECK_INTERVAL),
|
||||
}
|
||||
}
|
||||
}
|
||||
43
src-tauri/src/services/settings/types/theme.rs
Normal file
43
src-tauri/src/services/settings/types/theme.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use serde::Serialize;
|
||||
|
||||
const SYSTEM: &str = "system";
|
||||
const LIGHT: &str = "light";
|
||||
const DARK: &str = "dark";
|
||||
|
||||
#[derive(Debug, Serialize, Default)]
|
||||
pub enum Theme {
|
||||
#[default]
|
||||
System,
|
||||
Light,
|
||||
Dark,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Theme {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Theme::System => write!(f, "{}", SYSTEM),
|
||||
Theme::Light => write!(f, "{}", LIGHT),
|
||||
Theme::Dark => write!(f, "{}", DARK),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Option<String>> for Theme {
|
||||
fn from(s: Option<String>) -> Self {
|
||||
match s {
|
||||
Some(s) => match s.as_str() {
|
||||
SYSTEM => Theme::System,
|
||||
LIGHT => Theme::Light,
|
||||
DARK => Theme::Dark,
|
||||
_ => Theme::default(),
|
||||
},
|
||||
None => Theme::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for Theme {
|
||||
fn from(s: String) -> Self {
|
||||
Theme::from(Some(s))
|
||||
}
|
||||
}
|
||||
39
src-tauri/src/services/settings/types/view.rs
Normal file
39
src-tauri/src/services/settings/types/view.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use serde::Serialize;
|
||||
|
||||
const COMBINED: &str = "combined";
|
||||
const SPLIT: &str = "split";
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
pub enum View {
|
||||
#[default]
|
||||
Combined,
|
||||
Split,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for View {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
View::Combined => write!(f, "{}", COMBINED),
|
||||
View::Split => write!(f, "{}", SPLIT),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Option<String>> for View {
|
||||
fn from(s: Option<String>) -> Self {
|
||||
match s {
|
||||
Some(s) => match s.as_str() {
|
||||
COMBINED => View::Combined,
|
||||
SPLIT => View::Split,
|
||||
_ => View::default(),
|
||||
},
|
||||
None => View::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for View {
|
||||
fn from(s: String) -> Self {
|
||||
View::from(Some(s))
|
||||
}
|
||||
}
|
||||
2
src-tauri/src/services/transactions/mod.rs
Normal file
2
src-tauri/src/services/transactions/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod service;
|
||||
pub mod types;
|
||||
1532
src-tauri/src/services/transactions/service.rs
Normal file
1532
src-tauri/src/services/transactions/service.rs
Normal file
File diff suppressed because it is too large
Load Diff
80
src-tauri/src/services/transactions/types/inputs.rs
Normal file
80
src-tauri/src/services/transactions/types/inputs.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::services::balance_calculator::service::TransactionType;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateTransactionInput {
|
||||
pub account_id: String,
|
||||
pub transaction_type: TransactionType,
|
||||
pub gross_amount: String,
|
||||
pub tax_amount: Option<String>,
|
||||
pub net_amount: String,
|
||||
pub currency: String,
|
||||
pub description: String,
|
||||
pub merchant: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
pub receipt_paths: Option<Vec<String>>,
|
||||
pub transaction_date: String,
|
||||
pub tag_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct BulkCreateTransactionInput {
|
||||
pub transactions: Vec<CreateTransactionInput>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct UpdateTransactionInput {
|
||||
pub gross_amount: Option<String>,
|
||||
pub tax_amount: Option<String>,
|
||||
pub net_amount: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub merchant: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
pub transaction_date: Option<String>,
|
||||
pub receipt_paths: Option<Vec<String>>,
|
||||
pub tag_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct TransactionFilter {
|
||||
pub account_id: Option<String>,
|
||||
pub transaction_type: Option<TransactionType>,
|
||||
pub start_date: Option<String>,
|
||||
pub end_date: Option<String>,
|
||||
pub needs_review: Option<bool>,
|
||||
pub is_auto_inserted: Option<bool>,
|
||||
pub search_query: Option<String>,
|
||||
pub sort_by: Option<String>,
|
||||
pub sort_order: Option<String>,
|
||||
pub limit: Option<u64>,
|
||||
pub offset: Option<u64>,
|
||||
/// Filter by tag IDs (optional)
|
||||
pub tag_ids: Option<Vec<String>>,
|
||||
/// If true, transaction must have ALL specified tags. If false, ANY tag matches.
|
||||
pub match_all_tags: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct BulkDeleteInput {
|
||||
pub ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct BulkDeleteResult {
|
||||
pub deleted_count: usize,
|
||||
pub failed_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct TransactionStatistics {
|
||||
pub total_income: String,
|
||||
pub total_expense: String,
|
||||
pub total_transfer_in: String,
|
||||
pub total_transfer_out: String,
|
||||
pub net_flow: String,
|
||||
pub count_income: i64,
|
||||
pub count_expense: i64,
|
||||
pub count_transfer: i64,
|
||||
pub total_count: i64,
|
||||
}
|
||||
2
src-tauri/src/services/transactions/types/mod.rs
Normal file
2
src-tauri/src/services/transactions/types/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod inputs;
|
||||
pub mod outputs;
|
||||
15
src-tauri/src/services/transactions/types/outputs.rs
Normal file
15
src-tauri/src/services/transactions/types/outputs.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::db::entities::transactions;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TransactionWithTags {
|
||||
pub transaction: transactions::Model,
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct BulkDeleteResult {
|
||||
pub deleted_count: usize,
|
||||
pub failed_ids: Vec<String>,
|
||||
}
|
||||
63
src-tauri/src/state.rs
Normal file
63
src-tauri/src/state.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,14 @@
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"android": {
|
||||
"autoIncrementVersionCode": false,
|
||||
"minSdkVersion": 24
|
||||
},
|
||||
"iOS": {
|
||||
"minimumSystemVersion": "14.0"
|
||||
},
|
||||
"category": "Finance",
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user