mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-29 08:39:03 +00:00
Compare commits
49 Commits
2025.9.5
...
eb27f19196
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb27f19196 | ||
|
|
969776fb53 | ||
|
|
077e2bb1ad | ||
|
|
da98b7b2b7 | ||
|
|
2467cb2f5a | ||
|
|
5ae3a51d81 | ||
|
|
d09cf6d04c | ||
|
|
2c6e099596 | ||
|
|
990d0295b3 | ||
|
|
318ca517f7 | ||
|
|
0e645d9bae | ||
|
|
d51aa9429e | ||
|
|
c8f0a103c6 | ||
|
|
5987a759b8 | ||
|
|
6bfbed8fb6 | ||
|
|
b7e4ec4a1b | ||
|
|
35b6d98e6a | ||
|
|
3e248f95a8 | ||
|
|
e136fc4b75 | ||
|
|
692bee574e | ||
|
|
482f16c77e | ||
|
|
c6ac4455f8 | ||
|
|
ac0fedd8b2 | ||
|
|
06cf02f43f | ||
|
|
23aa8b08d4 | ||
|
|
2b69b1e27b | ||
|
|
4dec8113fe | ||
|
|
28534e97c0 | ||
|
|
43b6f32145 | ||
|
|
b3eab6ae26 | ||
|
|
a5d10b3539 | ||
|
|
1c901a9dda | ||
|
|
1e94333d8f | ||
|
|
4006dd128e | ||
|
|
7d9744a40e | ||
|
|
8654471042 | ||
|
|
e9711339bd | ||
|
|
0c030efef2 | ||
|
|
e4e04ea34e | ||
|
|
f4bf549b99 | ||
|
|
8cc4f567f8 | ||
|
|
a939b841f3 | ||
|
|
caa43e8eb0 | ||
|
|
0a8750ea36 | ||
|
|
2d6800eff8 | ||
|
|
544527f282 | ||
|
|
91020e32ea | ||
|
|
5a823d62f0 | ||
|
|
a00d6ce2ce |
55
.github/workflows/ci.yml
vendored
Normal file
55
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "main", "dev" ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ "main", "dev" ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-python:
|
||||||
|
name: Test Python
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v5
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version-file: "pyproject.toml"
|
||||||
|
|
||||||
|
- name: Create config directory for tests
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.config/leggen
|
||||||
|
cp config.example.toml ~/.config/leggen/config.toml
|
||||||
|
|
||||||
|
- name: Run Python tests
|
||||||
|
run: uv run pytest
|
||||||
|
|
||||||
|
test-frontend:
|
||||||
|
name: Test Frontend
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ./frontend
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Run lint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Run build
|
||||||
|
run: npm run build
|
||||||
31
.github/workflows/release.yml
vendored
31
.github/workflows/release.yml
vendored
@@ -133,3 +133,34 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta-frontend.outputs.tags }}
|
tags: ${{ steps.meta-frontend.outputs.tags }}
|
||||||
labels: ${{ steps.meta-frontend.outputs.labels }}
|
labels: ${{ steps.meta-frontend.outputs.labels }}
|
||||||
|
|
||||||
|
create-github-release:
|
||||||
|
name: Create GitHub Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [build, publish-to-pypi, push-docker-backend, push-docker-frontend]
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install git-cliff
|
||||||
|
run: |
|
||||||
|
wget -qO- https://github.com/orhun/git-cliff/releases/latest/download/git-cliff-2.10.0-x86_64-unknown-linux-gnu.tar.gz | tar xz
|
||||||
|
sudo mv git-cliff-*/git-cliff /usr/local/bin/
|
||||||
|
|
||||||
|
- name: Generate release notes
|
||||||
|
id: release_notes
|
||||||
|
run: |
|
||||||
|
echo "notes<<EOF" >> $GITHUB_OUTPUT
|
||||||
|
git-cliff --current >> $GITHUB_OUTPUT
|
||||||
|
echo "EOF" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Create Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ github.ref_name }}
|
||||||
|
name: Release ${{ github.ref_name }}
|
||||||
|
body: ${{ steps.release_notes.outputs.notes }}
|
||||||
|
draft: false
|
||||||
|
prerelease: false
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -162,3 +162,5 @@ docker-compose.dev.yml
|
|||||||
nocodb/
|
nocodb/
|
||||||
sql/
|
sql/
|
||||||
leggen.db
|
leggen.db
|
||||||
|
*.db
|
||||||
|
config.toml
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ repos:
|
|||||||
hooks:
|
hooks:
|
||||||
- id: mypy
|
- id: mypy
|
||||||
name: Static type check with mypy
|
name: Static type check with mypy
|
||||||
entry: uv run mypy leggen leggend --check-untyped-defs
|
entry: uv run mypy leggen --check-untyped-defs
|
||||||
files: "^leggen(d)?/.*"
|
files: "^leggen/.*"
|
||||||
language: "system"
|
language: "system"
|
||||||
types: ["python"]
|
types: ["python"]
|
||||||
always_run: true
|
always_run: true
|
||||||
|
|||||||
68
AGENTS.md
68
AGENTS.md
@@ -1,5 +1,55 @@
|
|||||||
# Agent Guidelines for Leggen
|
# Agent Guidelines for Leggen
|
||||||
|
|
||||||
|
## Quick Setup for Development
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- **uv** must be installed for Python dependency management (can be installed via `pip install uv`)
|
||||||
|
- **Configuration file**: Copy `config.example.toml` to `config.toml` before running any commands:
|
||||||
|
```bash
|
||||||
|
cp config.example.toml config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generate Mock Database
|
||||||
|
The leggen CLI provides a command to generate a mock database for testing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate sample database with default settings (3 accounts, 50 transactions each)
|
||||||
|
uv run leggen --config config.toml generate_sample_db --database /path/to/test.db --force
|
||||||
|
|
||||||
|
# Custom configuration
|
||||||
|
uv run leggen --config config.toml generate_sample_db --database ./test-data.db --accounts 5 --transactions 100 --force
|
||||||
|
```
|
||||||
|
|
||||||
|
The command outputs instructions for setting the required environment variable to use the generated database.
|
||||||
|
|
||||||
|
### Start the API Server
|
||||||
|
1. Install uv if not already installed: `pip install uv`
|
||||||
|
2. Set the database environment variable to point to your generated mock database:
|
||||||
|
```bash
|
||||||
|
export LEGGEN_DATABASE_PATH=/path/to/your/generated/database.db
|
||||||
|
```
|
||||||
|
3. Ensure the API can find the configuration file (choose one):
|
||||||
|
```bash
|
||||||
|
# Option 1: Copy config to the expected location
|
||||||
|
mkdir -p ~/.config/leggen && cp config.toml ~/.config/leggen/config.toml
|
||||||
|
|
||||||
|
# Option 2: Set environment variable to current config file
|
||||||
|
export LEGGEN_CONFIG_FILE=./config.toml
|
||||||
|
```
|
||||||
|
4. Start the API server:
|
||||||
|
```bash
|
||||||
|
uv run leggen server
|
||||||
|
```
|
||||||
|
- For development mode with auto-reload: `uv run leggen server --reload`
|
||||||
|
- API will be available at `http://localhost:8000` with docs at `http://localhost:8000/docs`
|
||||||
|
|
||||||
|
### Start the Frontend
|
||||||
|
1. Navigate to the frontend directory: `cd frontend`
|
||||||
|
2. Install npm dependencies: `npm install`
|
||||||
|
3. Start the development server: `npm run dev`
|
||||||
|
- Frontend will be available at `http://localhost:3000`
|
||||||
|
- The frontend is configured to connect to the API at `http://localhost:8000/api/v1`
|
||||||
|
|
||||||
## Build/Lint/Test Commands
|
## Build/Lint/Test Commands
|
||||||
|
|
||||||
### Frontend (React/TypeScript)
|
### Frontend (React/TypeScript)
|
||||||
@@ -10,7 +60,7 @@
|
|||||||
### Backend (Python)
|
### Backend (Python)
|
||||||
- **Lint**: `uv run ruff check .`
|
- **Lint**: `uv run ruff check .`
|
||||||
- **Format**: `uv run ruff format .`
|
- **Format**: `uv run ruff format .`
|
||||||
- **Type check**: `uv run mypy leggen leggend --check-untyped-defs`
|
- **Type check**: `uv run mypy leggen --check-untyped-defs`
|
||||||
- **All checks**: `uv run pre-commit run --all-files`
|
- **All checks**: `uv run pre-commit run --all-files`
|
||||||
- **Run all tests**: `uv run pytest`
|
- **Run all tests**: `uv run pytest`
|
||||||
- **Run single test**: `uv run pytest tests/unit/test_api_accounts.py::TestAccountsAPI::test_get_all_accounts_success -v`
|
- **Run single test**: `uv run pytest tests/unit/test_api_accounts.py::TestAccountsAPI::test_get_all_accounts_success -v`
|
||||||
@@ -37,6 +87,20 @@
|
|||||||
|
|
||||||
### General
|
### General
|
||||||
- **Formatting**: ruff for Python, ESLint for TypeScript
|
- **Formatting**: ruff for Python, ESLint for TypeScript
|
||||||
- **Commits**: Use conventional commits, run pre-commit hooks before pushing
|
- **Commits**: Use conventional commits with optional scopes, run pre-commit hooks before pushing
|
||||||
|
- Format: `type(scope): Description starting with uppercase and ending with period.`
|
||||||
|
- Scopes: `cli`, `api`, `frontend` (optional)
|
||||||
|
- Types: `feat`, `fix`, `refactor` (avoid too many different types)
|
||||||
|
- Examples:
|
||||||
|
- `feat(frontend): Add support for S3 backups.`
|
||||||
|
- `fix(api): Resolve authentication timeout issues.`
|
||||||
|
- `refactor(cli): Improve error handling for missing config.`
|
||||||
- Avoid including specific numbers, counts, or data-dependent information that may become outdated
|
- Avoid including specific numbers, counts, or data-dependent information that may become outdated
|
||||||
- **Security**: Never log sensitive data, use environment variables for secrets
|
- **Security**: Never log sensitive data, use environment variables for secrets
|
||||||
|
|
||||||
|
## Contributing Guidelines
|
||||||
|
|
||||||
|
This repository follows conventional changelog practices. Refer to `CONTRIBUTING.md` for detailed contribution guidelines including:
|
||||||
|
- Commit message format and scoping
|
||||||
|
- Release process using `scripts/release.sh`
|
||||||
|
- Pre-commit hooks setup with `pre-commit install`
|
||||||
|
|||||||
126
CHANGELOG.md
126
CHANGELOG.md
@@ -1,4 +1,130 @@
|
|||||||
|
|
||||||
|
## 2025.9.10 (2025/09/13)
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- **frontend:** Update dependencies. ([06cf02f4](https://github.com/elisiariocouto/leggen/commit/06cf02f43ff72e4e01692e3a94a06be48d9acb1f))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.10 (2025/09/13)
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- **frontend:** Update dependencies. ([06cf02f4](https://github.com/elisiariocouto/leggen/commit/06cf02f43ff72e4e01692e3a94a06be48d9acb1f))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.9 (2025/09/11)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **core:** Handle permission errors gracefully in database path creation. ([4006dd12](https://github.com/elisiariocouto/leggen/commit/4006dd128e0896b338cb93fad60a1eca90c1873d))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **frontend:** Improve transactions table mobile UX with responsive card layout ([1e94333d](https://github.com/elisiariocouto/leggen/commit/1e94333d8f0275542ae7fd6e49fb8b7f03ad3d11))
|
||||||
|
- **frontend:** Improve transactions table mobile UX with responsive card layout ([1c901a9d](https://github.com/elisiariocouto/leggen/commit/1c901a9ddab0f6515dce56df8cce74518805a6bb))
|
||||||
|
- Remove config.toml file - should be created when needed ([a5d10b35](https://github.com/elisiariocouto/leggen/commit/a5d10b3539e7cfc649b0fee05b12c4a03681e135))
|
||||||
|
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- **core:** Integrate directory creation with database path retrieval and remove backup file. ([7d9744a4](https://github.com/elisiariocouto/leggen/commit/7d9744a40e7898e5bbe52e2e9f54317aa5c1cdd6))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.9 (2025/09/11)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **core:** Handle permission errors gracefully in database path creation. ([4006dd12](https://github.com/elisiariocouto/leggen/commit/4006dd128e0896b338cb93fad60a1eca90c1873d))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **frontend:** Improve transactions table mobile UX with responsive card layout ([1e94333d](https://github.com/elisiariocouto/leggen/commit/1e94333d8f0275542ae7fd6e49fb8b7f03ad3d11))
|
||||||
|
- **frontend:** Improve transactions table mobile UX with responsive card layout ([1c901a9d](https://github.com/elisiariocouto/leggen/commit/1c901a9ddab0f6515dce56df8cce74518805a6bb))
|
||||||
|
- Remove config.toml file - should be created when needed ([a5d10b35](https://github.com/elisiariocouto/leggen/commit/a5d10b3539e7cfc649b0fee05b12c4a03681e135))
|
||||||
|
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- **core:** Integrate directory creation with database path retrieval and remove backup file. ([7d9744a4](https://github.com/elisiariocouto/leggen/commit/7d9744a40e7898e5bbe52e2e9f54317aa5c1cdd6))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.8 (2025/09/11)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Change branch name from develop to dev in CI workflow ([f4bf549b](https://github.com/elisiariocouto/leggen/commit/f4bf549b99197d70104abf5731ab1ccb67cc9a69))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Update CI workflow to use Node.js 20 instead of 18 ([e4e04ea3](https://github.com/elisiariocouto/leggen/commit/e4e04ea34ea568c08292562243b6e6c08234d918))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.8 (2025/09/11)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Change branch name from develop to dev in CI workflow ([f4bf549b](https://github.com/elisiariocouto/leggen/commit/f4bf549b99197d70104abf5731ab1ccb67cc9a69))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Update CI workflow to use Node.js 20 instead of 18 ([e4e04ea3](https://github.com/elisiariocouto/leggen/commit/e4e04ea34ea568c08292562243b6e6c08234d918))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.7 (2025/09/11)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Simplify notification settings and fix notification test on dashboard. ([91020e32](https://github.com/elisiariocouto/leggen/commit/91020e32ea836ee8af4aeaf5d49525c24b566aed))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **frontend:** Implement TanStack Table for transactions view ([544527f2](https://github.com/elisiariocouto/leggen/commit/544527f28284fb9644bec6e721fa5da8ce10739f))
|
||||||
|
- Improve transactions API pagination and search ([2d6800ef](https://github.com/elisiariocouto/leggen/commit/2d6800eff8e484d3d175225f94d854706584a773))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.7 (2025/09/11)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Simplify notification settings and fix notification test on dashboard. ([91020e32](https://github.com/elisiariocouto/leggen/commit/91020e32ea836ee8af4aeaf5d49525c24b566aed))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **frontend:** Implement TanStack Table for transactions view ([544527f2](https://github.com/elisiariocouto/leggen/commit/544527f28284fb9644bec6e721fa5da8ce10739f))
|
||||||
|
- Improve transactions API pagination and search ([2d6800ef](https://github.com/elisiariocouto/leggen/commit/2d6800eff8e484d3d175225f94d854706584a773))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.6 (2025/09/10)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **db:** Migrate transactions table to composite primary key ([a00d6ce2](https://github.com/elisiariocouto/leggen/commit/a00d6ce2ce2c4a070e9fae56c0cea58b3aab6cec))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.6 (2025/09/10)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **db:** Migrate transactions table to composite primary key ([a00d6ce2](https://github.com/elisiariocouto/leggen/commit/a00d6ce2ce2c4a070e9fae56c0cea58b3aab6cec))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 2025.9.5 (2025/09/10)
|
## 2025.9.5 (2025/09/10)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ FROM python:3.13-alpine
|
|||||||
LABEL org.opencontainers.image.source="https://github.com/elisiariocouto/leggen"
|
LABEL org.opencontainers.image.source="https://github.com/elisiariocouto/leggen"
|
||||||
LABEL org.opencontainers.image.authors="Elisiário Couto <elisiario@couto.io>"
|
LABEL org.opencontainers.image.authors="Elisiário Couto <elisiario@couto.io>"
|
||||||
LABEL org.opencontainers.image.licenses="MIT"
|
LABEL org.opencontainers.image.licenses="MIT"
|
||||||
LABEL org.opencontainers.image.title="Leggend API"
|
LABEL org.opencontainers.image.title="Leggen API"
|
||||||
LABEL org.opencontainers.image.description="Open Banking API for Leggen"
|
LABEL org.opencontainers.image.description="Open Banking API for Leggen"
|
||||||
LABEL org.opencontainers.image.url="https://github.com/elisiariocouto/leggen"
|
LABEL org.opencontainers.image.url="https://github.com/elisiariocouto/leggen"
|
||||||
|
|
||||||
@@ -30,4 +30,4 @@ EXPOSE 8000
|
|||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s CMD wget -q --spider http://127.0.0.1:8000/api/v1/health || exit 1
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s CMD wget -q --spider http://127.0.0.1:8000/api/v1/health || exit 1
|
||||||
|
|
||||||
CMD ["/app/.venv/bin/leggend"]
|
CMD ["/app/.venv/bin/leggen", "server"]
|
||||||
|
|||||||
33
README.md
33
README.md
@@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
An Open Banking CLI and API service for managing bank connections and transactions.
|
An Open Banking CLI and API service for managing bank connections and transactions.
|
||||||
|
|
||||||
This tool provides **FastAPI backend service** (`leggend`), a **React Web Interface** and a **command-line interface** (`leggen`) to connect to banks using the GoCardless Open Banking API.
|
This tool provides a **unified command-line interface** (`leggen`) with both CLI commands and an integrated **FastAPI backend service**, plus a **React Web Interface** to connect to banks using the GoCardless Open Banking API.
|
||||||
|
|
||||||
Having your bank data accessible through both CLI and REST API gives you the power to backup, analyze, create reports, and integrate with other applications.
|
Having your bank data accessible through both CLI and REST API gives you the power to backup, analyze, create reports, and integrate with other applications.
|
||||||
|
|
||||||
## 🛠️ Technologies
|
## 🛠️ Technologies
|
||||||
|
|
||||||
### 🔌 API & Backend
|
### 🔌 API & Backend
|
||||||
- [FastAPI](https://fastapi.tiangolo.com/): High-performance async API backend (`leggend` service)
|
- [FastAPI](https://fastapi.tiangolo.com/): High-performance async API backend (integrated into `leggen server`)
|
||||||
- [GoCardless Open Banking API](https://developer.gocardless.com/bank-account-data/overview): for connecting to banks
|
- [GoCardless Open Banking API](https://developer.gocardless.com/bank-account-data/overview): for connecting to banks
|
||||||
- [APScheduler](https://apscheduler.readthedocs.io/): Background job scheduling with configurable cron
|
- [APScheduler](https://apscheduler.readthedocs.io/): Background job scheduling with configurable cron
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ For development or local installation:
|
|||||||
uv sync # or pip install -e .
|
uv sync # or pip install -e .
|
||||||
|
|
||||||
# Start the API service
|
# Start the API service
|
||||||
uv run leggend --reload # Development mode with auto-reload
|
uv run leggen server --reload # Development mode with auto-reload
|
||||||
|
|
||||||
# Use the CLI (in another terminal)
|
# Use the CLI (in another terminal)
|
||||||
uv run leggen --help
|
uv run leggen --help
|
||||||
@@ -152,19 +152,19 @@ case-sensitive = ["SpecificStore"]
|
|||||||
|
|
||||||
## 📖 Usage
|
## 📖 Usage
|
||||||
|
|
||||||
### API Service (`leggend`)
|
### API Service (`leggen server`)
|
||||||
|
|
||||||
Start the FastAPI backend service:
|
Start the FastAPI backend service:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Production mode
|
# Production mode
|
||||||
leggend
|
leggen server
|
||||||
|
|
||||||
# Development mode with auto-reload
|
# Development mode with auto-reload
|
||||||
leggend --reload
|
leggen server --reload
|
||||||
|
|
||||||
# Custom host and port
|
# Custom host and port
|
||||||
leggend --host 127.0.0.1 --port 8080
|
leggen server --host 127.0.0.1 --port 8080
|
||||||
```
|
```
|
||||||
|
|
||||||
**API Documentation**: Visit `http://localhost:8000/docs` for interactive API documentation.
|
**API Documentation**: Visit `http://localhost:8000/docs` for interactive API documentation.
|
||||||
@@ -207,7 +207,7 @@ leggen sync --force --wait
|
|||||||
leggen --api-url http://localhost:8080 status
|
leggen --api-url http://localhost:8080 status
|
||||||
|
|
||||||
# Set via environment variable
|
# Set via environment variable
|
||||||
export LEGGEND_API_URL=http://localhost:8080
|
export LEGGEN_API_URL=http://localhost:8080
|
||||||
leggen status
|
leggen status
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -223,7 +223,7 @@ docker compose -f compose.dev.yml ps
|
|||||||
|
|
||||||
# Check logs
|
# Check logs
|
||||||
docker compose -f compose.dev.yml logs frontend
|
docker compose -f compose.dev.yml logs frontend
|
||||||
docker compose -f compose.dev.yml logs leggend
|
docker compose -f compose.dev.yml logs leggen-server
|
||||||
|
|
||||||
# Stop development services
|
# Stop development services
|
||||||
docker compose -f compose.dev.yml down
|
docker compose -f compose.dev.yml down
|
||||||
@@ -239,7 +239,7 @@ docker compose ps
|
|||||||
|
|
||||||
# Check logs
|
# Check logs
|
||||||
docker compose logs frontend
|
docker compose logs frontend
|
||||||
docker compose logs leggend
|
docker compose logs leggen-server
|
||||||
|
|
||||||
# Access the web interface at http://localhost:3000
|
# Access the web interface at http://localhost:3000
|
||||||
# API documentation at http://localhost:8000/docs
|
# API documentation at http://localhost:8000/docs
|
||||||
@@ -290,7 +290,7 @@ cd leggen
|
|||||||
uv sync
|
uv sync
|
||||||
|
|
||||||
# Start API service with auto-reload
|
# Start API service with auto-reload
|
||||||
uv run leggend --reload
|
uv run leggen server --reload
|
||||||
|
|
||||||
# Use CLI commands
|
# Use CLI commands
|
||||||
uv run leggen status
|
uv run leggen status
|
||||||
@@ -333,13 +333,10 @@ The test suite includes:
|
|||||||
leggen/ # CLI application
|
leggen/ # CLI application
|
||||||
├── commands/ # CLI command implementations
|
├── commands/ # CLI command implementations
|
||||||
├── utils/ # Shared utilities
|
├── utils/ # Shared utilities
|
||||||
└── api_client.py # API client for leggend service
|
├── api/ # FastAPI API routes and models
|
||||||
|
|
||||||
leggend/ # FastAPI backend service
|
|
||||||
├── api/ # API routes and models
|
|
||||||
├── services/ # Business logic
|
├── services/ # Business logic
|
||||||
├── background/ # Background job scheduler
|
├── background/ # Background job scheduler
|
||||||
└── main.py # FastAPI application
|
└── api_client.py # API client for server communication
|
||||||
|
|
||||||
tests/ # Test suite
|
tests/ # Test suite
|
||||||
├── conftest.py # Shared test fixtures
|
├── conftest.py # Shared test fixtures
|
||||||
@@ -357,6 +354,10 @@ tests/ # Test suite
|
|||||||
3. Make your changes with tests
|
3. Make your changes with tests
|
||||||
4. Submit a pull request
|
4. Submit a pull request
|
||||||
|
|
||||||
|
The repository uses GitHub Actions for CI/CD:
|
||||||
|
- **CI**: Runs Python tests (`uv run pytest`) and frontend linting/build on every push
|
||||||
|
- **Release**: Creates GitHub releases with changelog when tags are pushed
|
||||||
|
|
||||||
## ⚠️ Notes
|
## ⚠️ Notes
|
||||||
- This project is in active development
|
- This project is in active development
|
||||||
- GoCardless API rate limits apply
|
- GoCardless API rate limits apply
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:3000:80"
|
- "127.0.0.1:3000:80"
|
||||||
environment:
|
environment:
|
||||||
- API_BACKEND_URL=${API_BACKEND_URL:-http://leggend:8000}
|
- API_BACKEND_URL=${API_BACKEND_URL:-http://leggen-server:8000}
|
||||||
depends_on:
|
depends_on:
|
||||||
leggend:
|
leggen-server:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
# FastAPI backend service
|
# FastAPI backend service
|
||||||
leggend:
|
leggen-server:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:3000:80"
|
- "127.0.0.1:3000:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
leggend:
|
leggen-server:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
# FastAPI backend service
|
# FastAPI backend service
|
||||||
leggend:
|
leggen-server:
|
||||||
image: ghcr.io/elisiariocouto/leggen:latest
|
image: ghcr.io/elisiariocouto/leggen:latest
|
||||||
restart: "unless-stopped"
|
restart: "unless-stopped"
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ enabled = true
|
|||||||
|
|
||||||
# Optional: Telegram notifications
|
# Optional: Telegram notifications
|
||||||
[notifications.telegram]
|
[notifications.telegram]
|
||||||
token = "your-bot-token"
|
api-key = "your-bot-token"
|
||||||
chat_id = 12345
|
chat-id = 12345
|
||||||
enabled = true
|
enabled = true
|
||||||
|
|
||||||
# Optional: Transaction filters for notifications
|
# Optional: Transaction filters for notifications
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ COPY --from=builder /app/dist /usr/share/nginx/html
|
|||||||
COPY default.conf.template /etc/nginx/templates/default.conf.template
|
COPY default.conf.template /etc/nginx/templates/default.conf.template
|
||||||
|
|
||||||
# Set default API backend URL (can be overridden at runtime)
|
# Set default API backend URL (can be overridden at runtime)
|
||||||
ENV API_BACKEND_URL=http://leggend:8000
|
ENV API_BACKEND_URL=http://leggen-server:8000
|
||||||
|
|
||||||
# Expose port 80
|
# Expose port 80
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ The frontend supports configurable API URLs through environment variables:
|
|||||||
|
|
||||||
- Uses relative URLs (`/api/v1`) that nginx proxies to the backend
|
- Uses relative URLs (`/api/v1`) that nginx proxies to the backend
|
||||||
- Configure nginx proxy target via `API_BACKEND_URL` environment variable
|
- Configure nginx proxy target via `API_BACKEND_URL` environment variable
|
||||||
- Default: `http://leggend:8000`
|
- Default: `http://leggen-server:8000`
|
||||||
|
|
||||||
**Docker Compose:**
|
**Docker Compose:**
|
||||||
|
|
||||||
|
|||||||
22
frontend/components.json
Normal file
22
frontend/components.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
1451
frontend/package-lock.json
generated
1451
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,18 +10,30 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tanstack/react-query": "^5.87.1",
|
"@tanstack/react-query": "^5.87.1",
|
||||||
"@tanstack/react-router": "^1.131.36",
|
"@tanstack/react-router": "^1.131.36",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@tanstack/router-cli": "^1.131.36",
|
"@tanstack/router-cli": "^1.131.36",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.542.0",
|
"lucide-react": "^0.542.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
|
"react-day-picker": "^9.10.0",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"tailwindcss": "^3.4.17"
|
"recharts": "^3.2.0",
|
||||||
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"tailwindcss-animate": "^1.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.33.0",
|
"@eslint/js": "^9.33.0",
|
||||||
|
|||||||
@@ -16,6 +16,40 @@ import { formatCurrency, formatDate } from "../lib/utils";
|
|||||||
import LoadingSpinner from "./LoadingSpinner";
|
import LoadingSpinner from "./LoadingSpinner";
|
||||||
import type { Account, Balance } from "../types/api";
|
import type { Account, Balance } from "../types/api";
|
||||||
|
|
||||||
|
// Helper function to get status indicator color and styles
|
||||||
|
const getStatusIndicator = (status: string) => {
|
||||||
|
const statusLower = status.toLowerCase();
|
||||||
|
|
||||||
|
switch (statusLower) {
|
||||||
|
case 'ready':
|
||||||
|
return {
|
||||||
|
color: 'bg-green-500',
|
||||||
|
tooltip: 'Ready',
|
||||||
|
};
|
||||||
|
case 'pending':
|
||||||
|
return {
|
||||||
|
color: 'bg-yellow-500',
|
||||||
|
tooltip: 'Pending',
|
||||||
|
};
|
||||||
|
case 'error':
|
||||||
|
case 'failed':
|
||||||
|
return {
|
||||||
|
color: 'bg-red-500',
|
||||||
|
tooltip: 'Error',
|
||||||
|
};
|
||||||
|
case 'inactive':
|
||||||
|
return {
|
||||||
|
color: 'bg-gray-500',
|
||||||
|
tooltip: 'Inactive',
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
color: 'bg-blue-500',
|
||||||
|
tooltip: status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export default function AccountsOverview() {
|
export default function AccountsOverview() {
|
||||||
const {
|
const {
|
||||||
data: accounts,
|
data: accounts,
|
||||||
@@ -201,14 +235,15 @@ export default function AccountsOverview() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={account.id}
|
key={account.id}
|
||||||
className="p-6 hover:bg-gray-50 transition-colors"
|
className="p-4 sm:p-6 hover:bg-gray-50 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
{/* Mobile layout - stack vertically */}
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
||||||
<div className="p-3 bg-gray-100 rounded-full">
|
<div className="flex items-start sm:items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
|
||||||
<Building2 className="h-6 w-6 text-gray-600" />
|
<div className="flex-shrink-0 p-2 sm:p-3 bg-gray-100 rounded-full">
|
||||||
|
<Building2 className="h-5 w-5 sm:h-6 sm:w-6 text-gray-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
{editingAccountId === account.id ? (
|
{editingAccountId === account.id ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
@@ -216,7 +251,7 @@ export default function AccountsOverview() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={editingName}
|
value={editingName}
|
||||||
onChange={(e) => setEditingName(e.target.value)}
|
onChange={(e) => setEditingName(e.target.value)}
|
||||||
className="flex-1 px-3 py-1 text-lg font-medium border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
className="flex-1 px-3 py-1 text-base sm:text-lg font-medium border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
placeholder="Account name"
|
placeholder="Account name"
|
||||||
name="search"
|
name="search"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
@@ -245,29 +280,29 @@ export default function AccountsOverview() {
|
|||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600 truncate">
|
||||||
{account.institution_id} • {account.status}
|
{account.institution_id}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2 min-w-0">
|
||||||
<h4 className="text-lg font-medium text-gray-900">
|
<h4 className="text-base sm:text-lg font-medium text-gray-900 truncate">
|
||||||
{account.name || "Unnamed Account"}
|
{account.name || "Unnamed Account"}
|
||||||
</h4>
|
</h4>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEditStart(account)}
|
onClick={() => handleEditStart(account)}
|
||||||
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
className="flex-shrink-0 p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
title="Edit account name"
|
title="Edit account name"
|
||||||
>
|
>
|
||||||
<Edit2 className="h-4 w-4" />
|
<Edit2 className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600 truncate">
|
||||||
{account.institution_id} • {account.status}
|
{account.institution_id}
|
||||||
</p>
|
</p>
|
||||||
{account.iban && (
|
{account.iban && (
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1 font-mono break-all sm:break-normal">
|
||||||
IBAN: {account.iban}
|
IBAN: {account.iban}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -276,25 +311,45 @@ export default function AccountsOverview() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-right">
|
{/* Balance and date section */}
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center justify-between sm:flex-col sm:items-end sm:text-right flex-shrink-0">
|
||||||
|
{/* Mobile: date/status on left, balance on right */}
|
||||||
|
{/* Desktop: balance on top, date/status on bottom */}
|
||||||
|
|
||||||
|
{/* Date and status indicator - left on mobile, bottom on desktop */}
|
||||||
|
<div className="flex items-center space-x-2 order-1 sm:order-2">
|
||||||
|
<div
|
||||||
|
className={`w-3 h-3 rounded-full ${getStatusIndicator(account.status).color} relative group cursor-help`}
|
||||||
|
role="img"
|
||||||
|
aria-label={`Account status: ${getStatusIndicator(account.status).tooltip}`}
|
||||||
|
>
|
||||||
|
{/* Tooltip */}
|
||||||
|
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 hidden group-hover:block bg-gray-900 text-white text-xs rounded py-1 px-2 whitespace-nowrap z-10">
|
||||||
|
{getStatusIndicator(account.status).tooltip}
|
||||||
|
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-2 border-transparent border-t-gray-900"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs sm:text-sm text-gray-500 whitespace-nowrap">
|
||||||
|
Updated{" "}
|
||||||
|
{formatDate(account.last_accessed || account.created)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Balance - right on mobile, top on desktop */}
|
||||||
|
<div className="flex items-center space-x-2 order-2 sm:order-1">
|
||||||
{isPositive ? (
|
{isPositive ? (
|
||||||
<TrendingUp className="h-4 w-4 text-green-500" />
|
<TrendingUp className="h-4 w-4 text-green-500" />
|
||||||
) : (
|
) : (
|
||||||
<TrendingDown className="h-4 w-4 text-red-500" />
|
<TrendingDown className="h-4 w-4 text-red-500" />
|
||||||
)}
|
)}
|
||||||
<p
|
<p
|
||||||
className={`text-lg font-semibold ${
|
className={`text-base sm:text-lg font-semibold ${
|
||||||
isPositive ? "text-green-600" : "text-red-600"
|
isPositive ? "text-green-600" : "text-red-600"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{formatCurrency(balance, currency)}
|
{formatCurrency(balance, currency)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
Updated{" "}
|
|
||||||
{formatDate(account.last_accessed || account.created)}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { apiClient } from "../lib/api";
|
import { apiClient } from "../lib/api";
|
||||||
import AccountsOverview from "./AccountsOverview";
|
import AccountsOverview from "./AccountsOverview";
|
||||||
import TransactionsList from "./TransactionsList";
|
import TransactionsTable from "./TransactionsTable";
|
||||||
import Notifications from "./Notifications";
|
import Notifications from "./Notifications";
|
||||||
import ErrorBoundary from "./ErrorBoundary";
|
import ErrorBoundary from "./ErrorBoundary";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
@@ -177,7 +177,7 @@ export default function Dashboard() {
|
|||||||
<main className="flex-1 overflow-y-auto p-6">
|
<main className="flex-1 overflow-y-auto p-6">
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
{activeTab === "overview" && <AccountsOverview />}
|
{activeTab === "overview" && <AccountsOverview />}
|
||||||
{activeTab === "transactions" && <TransactionsList />}
|
{activeTab === "transactions" && <TransactionsTable />}
|
||||||
{activeTab === "analytics" && (
|
{activeTab === "analytics" && (
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||||
|
|||||||
70
frontend/src/components/FiltersSkeleton.tsx
Normal file
70
frontend/src/components/FiltersSkeleton.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
export default function FiltersSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow animate-pulse">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="h-6 bg-gray-200 rounded w-32"></div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="h-8 bg-gray-200 rounded w-24"></div>
|
||||||
|
<div className="h-8 bg-gray-200 rounded w-20"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||||
|
{/* Quick Date Filters Skeleton */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-32 mb-3"></div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<div className="h-10 bg-gray-200 rounded-lg w-24"></div>
|
||||||
|
<div className="h-10 bg-gray-200 rounded-lg w-20"></div>
|
||||||
|
<div className="h-10 bg-gray-200 rounded-lg w-28"></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<div className="h-10 bg-gray-200 rounded-lg w-24"></div>
|
||||||
|
<div className="h-10 bg-gray-200 rounded-lg w-20"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Fields Skeleton */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div className="sm:col-span-2 lg:col-span-1">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-16 mb-1"></div>
|
||||||
|
<div className="h-10 bg-gray-200 rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-16 mb-1"></div>
|
||||||
|
<div className="h-10 bg-gray-200 rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-20 mb-1"></div>
|
||||||
|
<div className="h-10 bg-gray-200 rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-16 mb-1"></div>
|
||||||
|
<div className="h-10 bg-gray-200 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Amount Range Filters Skeleton */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4">
|
||||||
|
<div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-20 mb-1"></div>
|
||||||
|
<div className="h-10 bg-gray-200 rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-20 mb-1"></div>
|
||||||
|
<div className="h-10 bg-gray-200 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results Summary Skeleton */}
|
||||||
|
<div className="px-6 py-3 bg-gray-50 border-b border-gray-200">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-48"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -102,7 +102,7 @@ export default function Notifications() {
|
|||||||
if (!testService) return;
|
if (!testService) return;
|
||||||
|
|
||||||
testMutation.mutate({
|
testMutation.mutate({
|
||||||
service: testService,
|
service: testService.toLowerCase(),
|
||||||
message: testMessage,
|
message: testMessage,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -113,7 +113,7 @@ export default function Notifications() {
|
|||||||
`Are you sure you want to delete the ${serviceName} notification service?`,
|
`Are you sure you want to delete the ${serviceName} notification service?`,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
deleteServiceMutation.mutate(serviceName);
|
deleteServiceMutation.mutate(serviceName.toLowerCase());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
103
frontend/src/components/TransactionSkeleton.tsx
Normal file
103
frontend/src/components/TransactionSkeleton.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
interface TransactionSkeletonProps {
|
||||||
|
rows?: number;
|
||||||
|
view?: "table" | "mobile";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TransactionSkeleton({
|
||||||
|
rows = 5,
|
||||||
|
view = "table"
|
||||||
|
}: TransactionSkeletonProps) {
|
||||||
|
const skeletonRows = Array.from({ length: rows }, (_, index) => index);
|
||||||
|
|
||||||
|
if (view === "mobile") {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow divide-y divide-gray-200">
|
||||||
|
{skeletonRows.map((_, index) => (
|
||||||
|
<div key={index} className="p-4 animate-pulse">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div className="p-2 rounded-full bg-gray-200 flex-shrink-0">
|
||||||
|
<div className="h-4 w-4 bg-gray-300 rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 space-y-2">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-2/3"></div>
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-1/3"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right ml-3 flex-shrink-0 space-y-2">
|
||||||
|
<div className="h-6 bg-gray-200 rounded w-20"></div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-16 ml-auto"></div>
|
||||||
|
<div className="h-6 bg-gray-200 rounded w-12 ml-auto"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-20 animate-pulse"></div>
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-16 animate-pulse"></div>
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-12 animate-pulse"></div>
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-8 animate-pulse"></div>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{skeletonRows.map((_, index) => (
|
||||||
|
<tr key={index} className="animate-pulse">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div className="p-2 rounded-full bg-gray-200 flex-shrink-0">
|
||||||
|
<div className="h-4 w-4 bg-gray-300 rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-2/3"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="h-6 bg-gray-200 rounded w-24 ml-auto mb-1"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-20"></div>
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-16"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="h-6 bg-gray-200 rounded w-12"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,374 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
Filter,
|
|
||||||
Search,
|
|
||||||
TrendingUp,
|
|
||||||
TrendingDown,
|
|
||||||
Calendar,
|
|
||||||
RefreshCw,
|
|
||||||
AlertCircle,
|
|
||||||
X,
|
|
||||||
Eye,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { apiClient } from "../lib/api";
|
|
||||||
import { formatCurrency, formatDate } from "../lib/utils";
|
|
||||||
import LoadingSpinner from "./LoadingSpinner";
|
|
||||||
import RawTransactionModal from "./RawTransactionModal";
|
|
||||||
import type { Account, Transaction } from "../types/api";
|
|
||||||
|
|
||||||
export default function TransactionsList() {
|
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
|
||||||
const [selectedAccount, setSelectedAccount] = useState<string>("");
|
|
||||||
const [startDate, setStartDate] = useState("");
|
|
||||||
const [endDate, setEndDate] = useState("");
|
|
||||||
const [showFilters, setShowFilters] = useState(false);
|
|
||||||
const [showRawModal, setShowRawModal] = useState(false);
|
|
||||||
const [selectedTransaction, setSelectedTransaction] =
|
|
||||||
useState<Transaction | null>(null);
|
|
||||||
|
|
||||||
const { data: accounts } = useQuery<Account[]>({
|
|
||||||
queryKey: ["accounts"],
|
|
||||||
queryFn: apiClient.getAccounts,
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: transactions,
|
|
||||||
isLoading: transactionsLoading,
|
|
||||||
error: transactionsError,
|
|
||||||
refetch: refetchTransactions,
|
|
||||||
} = useQuery<Transaction[]>({
|
|
||||||
queryKey: ["transactions", selectedAccount, startDate, endDate],
|
|
||||||
queryFn: () =>
|
|
||||||
apiClient.getTransactions({
|
|
||||||
accountId: selectedAccount || undefined,
|
|
||||||
startDate: startDate || undefined,
|
|
||||||
endDate: endDate || undefined,
|
|
||||||
summaryOnly: false, // Always fetch raw transaction data
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const filteredTransactions = (transactions || []).filter((transaction) => {
|
|
||||||
// Additional validation (API client should have already filtered out invalid ones)
|
|
||||||
if (!transaction || !transaction.account_id) {
|
|
||||||
console.warn(
|
|
||||||
"Invalid transaction found after API filtering:",
|
|
||||||
transaction,
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const description = transaction.description || "";
|
|
||||||
const creditorName = transaction.creditor_name || "";
|
|
||||||
const debtorName = transaction.debtor_name || "";
|
|
||||||
const reference = transaction.reference || "";
|
|
||||||
|
|
||||||
const matchesSearch =
|
|
||||||
searchTerm === "" ||
|
|
||||||
description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
creditorName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
debtorName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
reference.toLowerCase().includes(searchTerm.toLowerCase());
|
|
||||||
|
|
||||||
return matchesSearch;
|
|
||||||
});
|
|
||||||
|
|
||||||
const clearFilters = () => {
|
|
||||||
setSearchTerm("");
|
|
||||||
setSelectedAccount("");
|
|
||||||
setStartDate("");
|
|
||||||
setEndDate("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleViewRaw = (transaction: Transaction) => {
|
|
||||||
setSelectedTransaction(transaction);
|
|
||||||
setShowRawModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseModal = () => {
|
|
||||||
setShowRawModal(false);
|
|
||||||
setSelectedTransaction(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasActiveFilters =
|
|
||||||
searchTerm || selectedAccount || startDate || endDate;
|
|
||||||
|
|
||||||
if (transactionsLoading) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow">
|
|
||||||
<LoadingSpinner message="Loading transactions..." />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (transactionsError) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
|
||||||
<div className="flex items-center justify-center text-center">
|
|
||||||
<div>
|
|
||||||
<AlertCircle className="h-12 w-12 text-red-400 mx-auto mb-4" />
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
|
||||||
Failed to load transactions
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 mb-4">
|
|
||||||
Unable to fetch transactions from the Leggen API.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => refetchTransactions()}
|
|
||||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Filters */}
|
|
||||||
<div className="bg-white rounded-lg shadow">
|
|
||||||
<div className="px-6 py-4 border-b border-gray-200">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="text-lg font-medium text-gray-900">Transactions</h3>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<button
|
|
||||||
onClick={clearFilters}
|
|
||||||
className="inline-flex items-center px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded-full hover:bg-gray-200 transition-colors"
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3 mr-1" />
|
|
||||||
Clear filters
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => setShowFilters(!showFilters)}
|
|
||||||
className="inline-flex items-center px-3 py-2 bg-blue-100 text-blue-700 rounded-md hover:bg-blue-200 transition-colors"
|
|
||||||
>
|
|
||||||
<Filter className="h-4 w-4 mr-2" />
|
|
||||||
Filters
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showFilters && (
|
|
||||||
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
||||||
{/* Search */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Search
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
placeholder="Description, name, reference..."
|
|
||||||
className="pl-10 pr-3 py-2 w-full border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Account Filter */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Account
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={selectedAccount}
|
|
||||||
onChange={(e) => setSelectedAccount(e.target.value)}
|
|
||||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
>
|
|
||||||
<option value="">All accounts</option>
|
|
||||||
{accounts?.map((account) => (
|
|
||||||
<option key={account.id} value={account.id}>
|
|
||||||
{account.name || "Unnamed Account"} (
|
|
||||||
{account.institution_id})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Start Date */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Start Date
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Calendar className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={startDate}
|
|
||||||
onChange={(e) => setStartDate(e.target.value)}
|
|
||||||
className="pl-10 pr-3 py-2 w-full border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* End Date */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
End Date
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Calendar className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={endDate}
|
|
||||||
onChange={(e) => setEndDate(e.target.value)}
|
|
||||||
className="pl-10 pr-3 py-2 w-full border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Results Summary */}
|
|
||||||
<div className="px-6 py-3 bg-gray-50 border-b border-gray-200">
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Showing {filteredTransactions.length} transaction
|
|
||||||
{filteredTransactions.length !== 1 ? "s" : ""}
|
|
||||||
{selectedAccount && accounts && (
|
|
||||||
<span className="ml-1">
|
|
||||||
for {accounts.find((acc) => acc.id === selectedAccount)?.name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Transactions List */}
|
|
||||||
{filteredTransactions.length === 0 ? (
|
|
||||||
<div className="bg-white rounded-lg shadow p-6 text-center">
|
|
||||||
<div className="text-gray-400 mb-4">
|
|
||||||
<TrendingUp className="h-12 w-12 mx-auto" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
|
||||||
No transactions found
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
{hasActiveFilters
|
|
||||||
? "Try adjusting your filters to see more results."
|
|
||||||
: "No transactions are available for the selected criteria."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="bg-white rounded-lg shadow divide-y divide-gray-200">
|
|
||||||
{filteredTransactions.map((transaction) => {
|
|
||||||
const account = accounts?.find(
|
|
||||||
(acc) => acc.id === transaction.account_id,
|
|
||||||
);
|
|
||||||
const isPositive = transaction.transaction_value > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`${transaction.account_id}-${transaction.transaction_id}`}
|
|
||||||
className="p-6 hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-start space-x-3">
|
|
||||||
<div
|
|
||||||
className={`p-2 rounded-full ${
|
|
||||||
isPositive ? "bg-green-100" : "bg-red-100"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isPositive ? (
|
|
||||||
<TrendingUp className="h-4 w-4 text-green-600" />
|
|
||||||
) : (
|
|
||||||
<TrendingDown className="h-4 w-4 text-red-600" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1">
|
|
||||||
<h4 className="text-sm font-medium text-gray-900 mb-1">
|
|
||||||
{transaction.description}
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
<div className="text-xs text-gray-500 space-y-1">
|
|
||||||
{account && (
|
|
||||||
<p>
|
|
||||||
{account.name || "Unnamed Account"} •{" "}
|
|
||||||
{account.institution_id}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(transaction.creditor_name ||
|
|
||||||
transaction.debtor_name) && (
|
|
||||||
<p>
|
|
||||||
{isPositive ? "From: " : "To: "}
|
|
||||||
{transaction.creditor_name ||
|
|
||||||
transaction.debtor_name}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{transaction.reference && (
|
|
||||||
<p>Ref: {transaction.reference}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{transaction.internal_transaction_id && (
|
|
||||||
<p>ID: {transaction.internal_transaction_id}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-right ml-4">
|
|
||||||
<div className="flex items-center justify-end space-x-2 mb-2">
|
|
||||||
<button
|
|
||||||
onClick={() => handleViewRaw(transaction)}
|
|
||||||
className="inline-flex items-center px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors"
|
|
||||||
title="View raw transaction data"
|
|
||||||
>
|
|
||||||
<Eye className="h-3 w-3 mr-1" />
|
|
||||||
Raw
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
className={`text-lg font-semibold ${
|
|
||||||
isPositive ? "text-green-600" : "text-red-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isPositive ? "+" : ""}
|
|
||||||
{formatCurrency(
|
|
||||||
transaction.transaction_value,
|
|
||||||
transaction.transaction_currency,
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
{transaction.transaction_date
|
|
||||||
? formatDate(transaction.transaction_date)
|
|
||||||
: "No date"}
|
|
||||||
</p>
|
|
||||||
{transaction.booking_date &&
|
|
||||||
transaction.booking_date !==
|
|
||||||
transaction.transaction_date && (
|
|
||||||
<p className="text-xs text-gray-400">
|
|
||||||
Booked: {formatDate(transaction.booking_date)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Raw Transaction Modal */}
|
|
||||||
<RawTransactionModal
|
|
||||||
isOpen={showRawModal}
|
|
||||||
onClose={handleCloseModal}
|
|
||||||
rawTransaction={selectedTransaction?.raw_transaction}
|
|
||||||
transactionId={selectedTransaction?.transaction_id || "unknown"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
812
frontend/src/components/TransactionsTable.tsx
Normal file
812
frontend/src/components/TransactionsTable.tsx
Normal file
@@ -0,0 +1,812 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
useReactTable,
|
||||||
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
|
flexRender,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import type {
|
||||||
|
ColumnDef,
|
||||||
|
SortingState,
|
||||||
|
ColumnFiltersState,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import {
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
RefreshCw,
|
||||||
|
AlertCircle,
|
||||||
|
Eye,
|
||||||
|
ChevronUp,
|
||||||
|
ChevronDown,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { apiClient } from "../lib/api";
|
||||||
|
import { formatCurrency, formatDate } from "../lib/utils";
|
||||||
|
import TransactionSkeleton from "./TransactionSkeleton";
|
||||||
|
import FiltersSkeleton from "./FiltersSkeleton";
|
||||||
|
import RawTransactionModal from "./RawTransactionModal";
|
||||||
|
import { FilterBar, type FilterState } from "./filters";
|
||||||
|
import type { Account, Transaction, ApiResponse, Balance } from "../types/api";
|
||||||
|
|
||||||
|
export default function TransactionsTable() {
|
||||||
|
// Filter state consolidated into a single object
|
||||||
|
const [filterState, setFilterState] = useState<FilterState>({
|
||||||
|
searchTerm: "",
|
||||||
|
selectedAccount: "",
|
||||||
|
startDate: "",
|
||||||
|
endDate: "",
|
||||||
|
minAmount: "",
|
||||||
|
maxAmount: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [showRawModal, setShowRawModal] = useState(false);
|
||||||
|
const [selectedTransaction, setSelectedTransaction] =
|
||||||
|
useState<Transaction | null>(null);
|
||||||
|
const [showRunningBalance, setShowRunningBalance] = useState(true);
|
||||||
|
|
||||||
|
// Pagination state
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [perPage, setPerPage] = useState(50);
|
||||||
|
|
||||||
|
// Debounced search state
|
||||||
|
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(filterState.searchTerm);
|
||||||
|
|
||||||
|
// Table state (remove pagination from table)
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||||
|
|
||||||
|
// Helper function to update filter state
|
||||||
|
const handleFilterChange = (key: keyof FilterState, value: string) => {
|
||||||
|
setFilterState((prev) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to clear all filters
|
||||||
|
const handleClearFilters = () => {
|
||||||
|
setFilterState({
|
||||||
|
searchTerm: "",
|
||||||
|
selectedAccount: "",
|
||||||
|
startDate: "",
|
||||||
|
endDate: "",
|
||||||
|
minAmount: "",
|
||||||
|
maxAmount: "",
|
||||||
|
});
|
||||||
|
setColumnFilters([]);
|
||||||
|
setCurrentPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debounce search term to prevent excessive API calls
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setDebouncedSearchTerm(filterState.searchTerm);
|
||||||
|
}, 300); // 300ms delay
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [filterState.searchTerm]);
|
||||||
|
|
||||||
|
// Reset pagination when search term changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (debouncedSearchTerm !== filterState.searchTerm) {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}
|
||||||
|
}, [debouncedSearchTerm, filterState.searchTerm]);
|
||||||
|
|
||||||
|
const { data: accounts } = useQuery<Account[]>({
|
||||||
|
queryKey: ["accounts"],
|
||||||
|
queryFn: apiClient.getAccounts,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: balances } = useQuery<Balance[]>({
|
||||||
|
queryKey: ["balances"],
|
||||||
|
queryFn: apiClient.getBalances,
|
||||||
|
enabled: showRunningBalance,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: transactionsResponse,
|
||||||
|
isLoading: transactionsLoading,
|
||||||
|
error: transactionsError,
|
||||||
|
refetch: refetchTransactions,
|
||||||
|
} = useQuery<ApiResponse<Transaction[]>>({
|
||||||
|
queryKey: [
|
||||||
|
"transactions",
|
||||||
|
filterState.selectedAccount,
|
||||||
|
filterState.startDate,
|
||||||
|
filterState.endDate,
|
||||||
|
currentPage,
|
||||||
|
perPage,
|
||||||
|
debouncedSearchTerm,
|
||||||
|
filterState.minAmount,
|
||||||
|
filterState.maxAmount,
|
||||||
|
],
|
||||||
|
queryFn: () =>
|
||||||
|
apiClient.getTransactions({
|
||||||
|
accountId: filterState.selectedAccount || undefined,
|
||||||
|
startDate: filterState.startDate || undefined,
|
||||||
|
endDate: filterState.endDate || undefined,
|
||||||
|
page: currentPage,
|
||||||
|
perPage: perPage,
|
||||||
|
search: debouncedSearchTerm || undefined,
|
||||||
|
summaryOnly: false,
|
||||||
|
minAmount: filterState.minAmount ? parseFloat(filterState.minAmount) : undefined,
|
||||||
|
maxAmount: filterState.maxAmount ? parseFloat(filterState.maxAmount) : undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const transactions = transactionsResponse?.data || [];
|
||||||
|
const pagination = transactionsResponse?.pagination;
|
||||||
|
|
||||||
|
// Check if search is currently debouncing
|
||||||
|
const isSearchLoading = filterState.searchTerm !== debouncedSearchTerm;
|
||||||
|
|
||||||
|
// Reset pagination when total becomes 0 (no results)
|
||||||
|
useEffect(() => {
|
||||||
|
if (pagination && pagination.total === 0 && currentPage > 1) {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}
|
||||||
|
}, [pagination, currentPage]);
|
||||||
|
|
||||||
|
// Reset pagination when filters change
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [filterState.selectedAccount, filterState.startDate, filterState.endDate, filterState.minAmount, filterState.maxAmount]);
|
||||||
|
|
||||||
|
const handleViewRaw = (transaction: Transaction) => {
|
||||||
|
setSelectedTransaction(transaction);
|
||||||
|
setShowRawModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setShowRawModal(false);
|
||||||
|
setSelectedTransaction(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasActiveFilters =
|
||||||
|
filterState.searchTerm ||
|
||||||
|
filterState.selectedAccount ||
|
||||||
|
filterState.startDate ||
|
||||||
|
filterState.endDate ||
|
||||||
|
filterState.minAmount ||
|
||||||
|
filterState.maxAmount;
|
||||||
|
|
||||||
|
// Calculate running balances
|
||||||
|
const calculateRunningBalances = (transactions: Transaction[]) => {
|
||||||
|
if (!balances || !showRunningBalance) return {};
|
||||||
|
|
||||||
|
const runningBalances: { [key: string]: number } = {};
|
||||||
|
const accountBalanceMap = new Map<string, number>();
|
||||||
|
|
||||||
|
// Create a map of account current balances
|
||||||
|
balances.forEach(balance => {
|
||||||
|
if (balance.balance_type === 'expected') {
|
||||||
|
accountBalanceMap.set(balance.account_id, balance.balance_amount);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group transactions by account
|
||||||
|
const transactionsByAccount = new Map<string, Transaction[]>();
|
||||||
|
transactions.forEach(txn => {
|
||||||
|
if (!transactionsByAccount.has(txn.account_id)) {
|
||||||
|
transactionsByAccount.set(txn.account_id, []);
|
||||||
|
}
|
||||||
|
transactionsByAccount.get(txn.account_id)!.push(txn);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate running balance for each account
|
||||||
|
transactionsByAccount.forEach((accountTransactions, accountId) => {
|
||||||
|
const currentBalance = accountBalanceMap.get(accountId) || 0;
|
||||||
|
let runningBalance = currentBalance;
|
||||||
|
|
||||||
|
// Sort transactions by date (newest first) to work backwards
|
||||||
|
const sortedTransactions = [...accountTransactions].sort((a, b) =>
|
||||||
|
new Date(b.transaction_date).getTime() - new Date(a.transaction_date).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate running balance by working backwards from current balance
|
||||||
|
sortedTransactions.forEach((txn) => {
|
||||||
|
runningBalances[`${txn.account_id}-${txn.transaction_id}`] = runningBalance;
|
||||||
|
runningBalance -= txn.transaction_value;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return runningBalances;
|
||||||
|
};
|
||||||
|
|
||||||
|
const runningBalances = calculateRunningBalances(transactions);
|
||||||
|
|
||||||
|
// Define columns
|
||||||
|
const columns: ColumnDef<Transaction>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "description",
|
||||||
|
header: "Description",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const transaction = row.original;
|
||||||
|
const account = accounts?.find(
|
||||||
|
(acc) => acc.id === transaction.account_id,
|
||||||
|
);
|
||||||
|
const isPositive = transaction.transaction_value > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div
|
||||||
|
className={`p-2 rounded-full ${
|
||||||
|
isPositive ? "bg-green-100" : "bg-red-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isPositive ? (
|
||||||
|
<TrendingUp className="h-4 w-4 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<TrendingDown className="h-4 w-4 text-red-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 truncate">
|
||||||
|
{transaction.description}
|
||||||
|
</h4>
|
||||||
|
<div className="text-xs text-gray-500 space-y-1">
|
||||||
|
{account && (
|
||||||
|
<p className="truncate">
|
||||||
|
{account.name || "Unnamed Account"} •{" "}
|
||||||
|
{account.institution_id}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{(transaction.creditor_name || transaction.debtor_name) && (
|
||||||
|
<p className="truncate">
|
||||||
|
{isPositive ? "From: " : "To: "}
|
||||||
|
{transaction.creditor_name || transaction.debtor_name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{transaction.reference && (
|
||||||
|
<p className="truncate">Ref: {transaction.reference}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "transaction_value",
|
||||||
|
header: "Amount",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const transaction = row.original;
|
||||||
|
const isPositive = transaction.transaction_value > 0;
|
||||||
|
return (
|
||||||
|
<div className="text-right">
|
||||||
|
<p
|
||||||
|
className={`text-lg font-semibold ${
|
||||||
|
isPositive ? "text-green-600" : "text-red-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isPositive ? "+" : ""}
|
||||||
|
{formatCurrency(
|
||||||
|
transaction.transaction_value,
|
||||||
|
transaction.transaction_currency,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
sortingFn: "basic",
|
||||||
|
},
|
||||||
|
...(showRunningBalance ? [{
|
||||||
|
id: "running_balance",
|
||||||
|
header: "Running Balance",
|
||||||
|
cell: ({ row }: { row: { original: Transaction } }) => {
|
||||||
|
const transaction = row.original;
|
||||||
|
const balanceKey = `${transaction.account_id}-${transaction.transaction_id}`;
|
||||||
|
const balance = runningBalances[balanceKey];
|
||||||
|
|
||||||
|
if (balance === undefined) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm font-medium text-gray-900">
|
||||||
|
{formatCurrency(balance, transaction.transaction_currency)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}] : []),
|
||||||
|
{
|
||||||
|
accessorKey: "transaction_date",
|
||||||
|
header: "Date",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const transaction = row.original;
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-gray-900">
|
||||||
|
{transaction.transaction_date
|
||||||
|
? formatDate(transaction.transaction_date)
|
||||||
|
: "No date"}
|
||||||
|
{transaction.booking_date &&
|
||||||
|
transaction.booking_date !== transaction.transaction_date && (
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
Booked: {formatDate(transaction.booking_date)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
sortingFn: "datetime",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: "",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const transaction = row.original;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => handleViewRaw(transaction)}
|
||||||
|
className="inline-flex items-center px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors"
|
||||||
|
title="View raw transaction data"
|
||||||
|
>
|
||||||
|
<Eye className="h-3 w-3 mr-1" />
|
||||||
|
Raw
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: transactions,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onColumnFiltersChange: setColumnFilters,
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
columnFilters,
|
||||||
|
globalFilter: filterState.searchTerm,
|
||||||
|
},
|
||||||
|
onGlobalFilterChange: (value: string) => handleFilterChange("searchTerm", value),
|
||||||
|
globalFilterFn: (row, _columnId, filterValue) => {
|
||||||
|
// Custom global filter that searches multiple fields
|
||||||
|
const transaction = row.original;
|
||||||
|
const searchLower = filterValue.toLowerCase();
|
||||||
|
|
||||||
|
const description = transaction.description || "";
|
||||||
|
const creditorName = transaction.creditor_name || "";
|
||||||
|
const debtorName = transaction.debtor_name || "";
|
||||||
|
const reference = transaction.reference || "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
description.toLowerCase().includes(searchLower) ||
|
||||||
|
creditorName.toLowerCase().includes(searchLower) ||
|
||||||
|
debtorName.toLowerCase().includes(searchLower) ||
|
||||||
|
reference.toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (transactionsLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<FiltersSkeleton />
|
||||||
|
<TransactionSkeleton rows={10} view="table" />
|
||||||
|
<div className="md:hidden">
|
||||||
|
<TransactionSkeleton rows={10} view="mobile" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transactionsError) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<div className="flex items-center justify-center text-center">
|
||||||
|
<div>
|
||||||
|
<AlertCircle className="h-12 w-12 text-red-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||||
|
Failed to load transactions
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 mb-4">
|
||||||
|
Unable to fetch transactions from the Leggen API.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => refetchTransactions()}
|
||||||
|
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* New FilterBar */}
|
||||||
|
<FilterBar
|
||||||
|
filterState={filterState}
|
||||||
|
onFilterChange={handleFilterChange}
|
||||||
|
onClearFilters={handleClearFilters}
|
||||||
|
accounts={accounts}
|
||||||
|
isSearchLoading={isSearchLoading}
|
||||||
|
showRunningBalance={showRunningBalance}
|
||||||
|
onToggleRunningBalance={() => setShowRunningBalance(!showRunningBalance)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Results Summary */}
|
||||||
|
<div className="bg-white rounded-lg shadow border">
|
||||||
|
<div className="px-6 py-3 bg-gray-50 border-b border-gray-200">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Showing {transactions.length} transaction
|
||||||
|
{transactions.length !== 1 ? "s" : ""} (
|
||||||
|
{pagination ? (
|
||||||
|
<>
|
||||||
|
{(pagination.page - 1) * pagination.per_page + 1}-
|
||||||
|
{Math.min(
|
||||||
|
pagination.page * pagination.per_page,
|
||||||
|
pagination.total,
|
||||||
|
)}{" "}
|
||||||
|
of {pagination.total}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"loading..."
|
||||||
|
)}
|
||||||
|
)
|
||||||
|
{filterState.selectedAccount && accounts && (
|
||||||
|
<span className="ml-1">
|
||||||
|
for {accounts.find((acc) => acc.id === filterState.selectedAccount)?.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Responsive Table/Cards */}
|
||||||
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
{/* Desktop Table View (hidden on mobile) */}
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<th
|
||||||
|
key={header.id}
|
||||||
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||||
|
onClick={header.column.getToggleSortingHandler()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<span>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{header.column.getCanSort() && (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<ChevronUp
|
||||||
|
className={`h-3 w-3 ${
|
||||||
|
header.column.getIsSorted() === "asc"
|
||||||
|
? "text-blue-600"
|
||||||
|
: "text-gray-400"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<ChevronDown
|
||||||
|
className={`h-3 w-3 -mt-1 ${
|
||||||
|
header.column.getIsSorted() === "desc"
|
||||||
|
? "text-blue-600"
|
||||||
|
: "text-gray-400"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{table.getRowModel().rows.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="px-6 py-12 text-center"
|
||||||
|
>
|
||||||
|
<div className="text-gray-400 mb-4">
|
||||||
|
<TrendingUp className="h-12 w-12 mx-auto" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||||
|
No transactions found
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{hasActiveFilters
|
||||||
|
? "Try adjusting your filters to see more results."
|
||||||
|
: "No transactions are available for the selected criteria."}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<tr key={row.id} className="hover:bg-gray-50">
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<td key={cell.id} className="px-6 py-4 whitespace-nowrap">
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext(),
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Card View (visible only on mobile) */}
|
||||||
|
<div className="md:hidden">
|
||||||
|
{table.getRowModel().rows.length === 0 ? (
|
||||||
|
<div className="px-6 py-12 text-center">
|
||||||
|
<div className="text-gray-400 mb-4">
|
||||||
|
<TrendingUp className="h-12 w-12 mx-auto" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||||
|
No transactions found
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{hasActiveFilters
|
||||||
|
? "Try adjusting your filters to see more results."
|
||||||
|
: "No transactions are available for the selected criteria."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-200">
|
||||||
|
{table.getRowModel().rows.map((row) => {
|
||||||
|
const transaction = row.original;
|
||||||
|
const account = accounts?.find(
|
||||||
|
(acc) => acc.id === transaction.account_id,
|
||||||
|
);
|
||||||
|
const isPositive = transaction.transaction_value > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={row.id}
|
||||||
|
className="p-4 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div
|
||||||
|
className={`p-2 rounded-full flex-shrink-0 ${
|
||||||
|
isPositive ? "bg-green-100" : "bg-red-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isPositive ? (
|
||||||
|
<TrendingUp className="h-4 w-4 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<TrendingDown className="h-4 w-4 text-red-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 break-words">
|
||||||
|
{transaction.description}
|
||||||
|
</h4>
|
||||||
|
<div className="text-xs text-gray-500 space-y-1 mt-1">
|
||||||
|
{account && (
|
||||||
|
<p className="break-words">
|
||||||
|
{account.name || "Unnamed Account"} •{" "}
|
||||||
|
{account.institution_id}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{(transaction.creditor_name || transaction.debtor_name) && (
|
||||||
|
<p className="break-words">
|
||||||
|
{isPositive ? "From: " : "To: "}
|
||||||
|
{transaction.creditor_name || transaction.debtor_name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{transaction.reference && (
|
||||||
|
<p className="break-words">Ref: {transaction.reference}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-gray-400">
|
||||||
|
{transaction.transaction_date
|
||||||
|
? formatDate(transaction.transaction_date)
|
||||||
|
: "No date"}
|
||||||
|
{transaction.booking_date &&
|
||||||
|
transaction.booking_date !== transaction.transaction_date && (
|
||||||
|
<span className="ml-2">
|
||||||
|
(Booked: {formatDate(transaction.booking_date)})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right ml-3 flex-shrink-0">
|
||||||
|
<p
|
||||||
|
className={`text-lg font-semibold mb-1 ${
|
||||||
|
isPositive ? "text-green-600" : "text-red-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isPositive ? "+" : ""}
|
||||||
|
{formatCurrency(
|
||||||
|
transaction.transaction_value,
|
||||||
|
transaction.transaction_currency,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{showRunningBalance && (
|
||||||
|
<p className="text-xs text-gray-500 mb-1">
|
||||||
|
Balance: {formatCurrency(
|
||||||
|
runningBalances[`${transaction.account_id}-${transaction.transaction_id}`] || 0,
|
||||||
|
transaction.transaction_currency,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handleViewRaw(transaction)}
|
||||||
|
className="inline-flex items-center px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors"
|
||||||
|
title="View raw transaction data"
|
||||||
|
>
|
||||||
|
<Eye className="h-3 w-3 mr-1" />
|
||||||
|
Raw
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{pagination && (
|
||||||
|
<div className="bg-white px-4 py-3 flex flex-col sm:flex-row items-center justify-between border-t border-gray-200 space-y-3 sm:space-y-0">
|
||||||
|
{/* Mobile pagination controls */}
|
||||||
|
<div className="flex justify-between w-full sm:hidden">
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(1)}
|
||||||
|
disabled={pagination.page === 1}
|
||||||
|
className="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
First
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setCurrentPage((prev) => Math.max(1, prev - 1))
|
||||||
|
}
|
||||||
|
disabled={!pagination.has_prev}
|
||||||
|
className="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage((prev) => prev + 1)}
|
||||||
|
disabled={!pagination.has_next}
|
||||||
|
className="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(pagination.total_pages)}
|
||||||
|
disabled={pagination.page === pagination.total_pages}
|
||||||
|
className="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Last
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile pagination info */}
|
||||||
|
<div className="text-center w-full sm:hidden">
|
||||||
|
<p className="text-sm text-gray-700">
|
||||||
|
Page <span className="font-medium">{pagination.page}</span> of{" "}
|
||||||
|
<span className="font-medium">{pagination.total_pages}</span>
|
||||||
|
<br />
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
Showing {(pagination.page - 1) * pagination.per_page + 1}-
|
||||||
|
{Math.min(pagination.page * pagination.per_page, pagination.total)} of {pagination.total}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop pagination */}
|
||||||
|
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<p className="text-sm text-gray-700">
|
||||||
|
Showing{" "}
|
||||||
|
<span className="font-medium">
|
||||||
|
{(pagination.page - 1) * pagination.per_page + 1}
|
||||||
|
</span>{" "}
|
||||||
|
to{" "}
|
||||||
|
<span className="font-medium">
|
||||||
|
{Math.min(
|
||||||
|
pagination.page * pagination.per_page,
|
||||||
|
pagination.total,
|
||||||
|
)}
|
||||||
|
</span>{" "}
|
||||||
|
of <span className="font-medium">{pagination.total}</span>{" "}
|
||||||
|
results
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<label className="text-sm text-gray-700">
|
||||||
|
Rows per page:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={perPage}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPerPage(Number(e.target.value));
|
||||||
|
setCurrentPage(1); // Reset to first page when changing page size
|
||||||
|
}}
|
||||||
|
className="border border-gray-300 rounded px-2 py-1 text-sm"
|
||||||
|
>
|
||||||
|
{[10, 25, 50, 100].map((pageSize) => (
|
||||||
|
<option key={pageSize} value={pageSize}>
|
||||||
|
{pageSize}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(1)}
|
||||||
|
disabled={pagination.page === 1}
|
||||||
|
className="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
First
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setCurrentPage((prev) => Math.max(1, prev - 1))
|
||||||
|
}
|
||||||
|
disabled={!pagination.has_prev}
|
||||||
|
className="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-gray-700">
|
||||||
|
Page <span className="font-medium">{pagination.page}</span>{" "}
|
||||||
|
of{" "}
|
||||||
|
<span className="font-medium">
|
||||||
|
{pagination.total_pages}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage((prev) => prev + 1)}
|
||||||
|
disabled={!pagination.has_next}
|
||||||
|
className="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(pagination.total_pages)}
|
||||||
|
disabled={pagination.page === pagination.total_pages}
|
||||||
|
className="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Last
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Raw Transaction Modal */}
|
||||||
|
<RawTransactionModal
|
||||||
|
isOpen={showRawModal}
|
||||||
|
onClose={handleCloseModal}
|
||||||
|
rawTransaction={selectedTransaction?.raw_transaction}
|
||||||
|
transactionId={selectedTransaction?.transaction_id || "unknown"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
157
frontend/src/components/analytics/BalanceChart.tsx
Normal file
157
frontend/src/components/analytics/BalanceChart.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import {
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Legend,
|
||||||
|
} from "recharts";
|
||||||
|
import type { Balance, Account } from "../../types/api";
|
||||||
|
|
||||||
|
interface BalanceChartProps {
|
||||||
|
data: Balance[];
|
||||||
|
accounts: Account[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChartDataPoint {
|
||||||
|
date: string;
|
||||||
|
balance: number;
|
||||||
|
account_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AggregatedDataPoint {
|
||||||
|
date: string;
|
||||||
|
[key: string]: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BalanceChart({ data, accounts, className }: BalanceChartProps) {
|
||||||
|
// Create a lookup map for account info
|
||||||
|
const accountMap = accounts.reduce((map, account) => {
|
||||||
|
map[account.id] = account;
|
||||||
|
return map;
|
||||||
|
}, {} as Record<string, Account>);
|
||||||
|
|
||||||
|
// Helper function to get bank name from institution_id
|
||||||
|
const getBankName = (institutionId: string): string => {
|
||||||
|
const bankMapping: Record<string, string> = {
|
||||||
|
'REVOLUT_REVOLT21': 'Revolut',
|
||||||
|
'NUBANK_NUPBBR25': 'Nu Pagamentos',
|
||||||
|
'BANCOBPI_BBPIPTPL': 'Banco BPI',
|
||||||
|
// Add more mappings as needed
|
||||||
|
};
|
||||||
|
return bankMapping[institutionId] || institutionId.split('_')[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to create display name for account
|
||||||
|
const getAccountDisplayName = (accountId: string): string => {
|
||||||
|
const account = accountMap[accountId];
|
||||||
|
if (account) {
|
||||||
|
const bankName = getBankName(account.institution_id);
|
||||||
|
const accountName = account.name || `Account ${accountId.split('-')[1]}`;
|
||||||
|
return `${bankName} - ${accountName}`;
|
||||||
|
}
|
||||||
|
return `Account ${accountId.split('-')[1]}`;
|
||||||
|
};
|
||||||
|
// Process balance data for the chart
|
||||||
|
const chartData = data
|
||||||
|
.filter((balance) => balance.balance_type === "closingBooked")
|
||||||
|
.map((balance) => ({
|
||||||
|
date: new Date(balance.reference_date).toLocaleDateString('en-GB'), // DD/MM/YYYY format
|
||||||
|
balance: balance.balance_amount,
|
||||||
|
account_id: balance.account_id,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => new Date(a.date.split('/').reverse().join('/')).getTime() - new Date(b.date.split('/').reverse().join('/')).getTime());
|
||||||
|
|
||||||
|
// Group by account and aggregate
|
||||||
|
const accountBalances: { [key: string]: ChartDataPoint[] } = {};
|
||||||
|
chartData.forEach((item) => {
|
||||||
|
if (!accountBalances[item.account_id]) {
|
||||||
|
accountBalances[item.account_id] = [];
|
||||||
|
}
|
||||||
|
accountBalances[item.account_id].push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create aggregated data points
|
||||||
|
const aggregatedData: { [key: string]: AggregatedDataPoint } = {};
|
||||||
|
Object.entries(accountBalances).forEach(([accountId, balances]) => {
|
||||||
|
balances.forEach((balance) => {
|
||||||
|
if (!aggregatedData[balance.date]) {
|
||||||
|
aggregatedData[balance.date] = { date: balance.date };
|
||||||
|
}
|
||||||
|
aggregatedData[balance.date][accountId] = balance.balance;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const finalData = Object.values(aggregatedData).sort(
|
||||||
|
(a, b) => new Date(a.date.split('/').reverse().join('/')).getTime() - new Date(b.date.split('/').reverse().join('/')).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
const colors = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6"];
|
||||||
|
|
||||||
|
if (finalData.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||||
|
Balance Progress
|
||||||
|
</h3>
|
||||||
|
<div className="h-80 flex items-center justify-center text-gray-500">
|
||||||
|
No balance data available
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||||
|
Balance Progress Over Time
|
||||||
|
</h3>
|
||||||
|
<div className="h-80">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={finalData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
tickFormatter={(value) => {
|
||||||
|
// Convert DD/MM/YYYY back to a proper date for formatting
|
||||||
|
const [day, month, year] = value.split('/');
|
||||||
|
const date = new Date(year, month - 1, day);
|
||||||
|
return date.toLocaleDateString('en-GB', {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
tickFormatter={(value) => `€${value.toLocaleString()}`}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number, name: string) => [
|
||||||
|
`€${value.toLocaleString()}`,
|
||||||
|
getAccountDisplayName(name),
|
||||||
|
]}
|
||||||
|
labelFormatter={(label) => `Date: ${label}`}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
{Object.keys(accountBalances).map((accountId, index) => (
|
||||||
|
<Area
|
||||||
|
key={accountId}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={accountId}
|
||||||
|
stackId="1"
|
||||||
|
fill={colors[index % colors.length]}
|
||||||
|
stroke={colors[index % colors.length]}
|
||||||
|
name={getAccountDisplayName(accountId)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
frontend/src/components/analytics/MonthlyTrends.tsx
Normal file
137
frontend/src/components/analytics/MonthlyTrends.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from "recharts";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import apiClient from "../../lib/api";
|
||||||
|
|
||||||
|
interface MonthlyTrendsProps {
|
||||||
|
className?: string;
|
||||||
|
days?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
interface TooltipProps {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: Array<{
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
color: string;
|
||||||
|
}>;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MonthlyTrends({ className, days = 365 }: MonthlyTrendsProps) {
|
||||||
|
// Get pre-calculated monthly stats from the new endpoint
|
||||||
|
const { data: monthlyData, isLoading } = useQuery({
|
||||||
|
queryKey: ["monthly-stats", days],
|
||||||
|
queryFn: async () => {
|
||||||
|
return await apiClient.getMonthlyTransactionStats(days);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate number of months to display based on days filter
|
||||||
|
const getMonthsToDisplay = (days: number): number => {
|
||||||
|
if (days <= 30) return 1;
|
||||||
|
if (days <= 180) return 6;
|
||||||
|
if (days <= 365) return 12;
|
||||||
|
return Math.ceil(days / 30);
|
||||||
|
};
|
||||||
|
|
||||||
|
const monthsToDisplay = getMonthsToDisplay(days);
|
||||||
|
const displayData = monthlyData ? monthlyData.slice(-monthsToDisplay) : [];
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||||
|
Monthly Spending Trends
|
||||||
|
</h3>
|
||||||
|
<div className="h-80 flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayData.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||||
|
Monthly Spending Trends
|
||||||
|
</h3>
|
||||||
|
<div className="h-80 flex items-center justify-center text-gray-500">
|
||||||
|
No transaction data available
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload, label }: TooltipProps) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white p-3 border rounded shadow-lg">
|
||||||
|
<p className="font-medium">{label}</p>
|
||||||
|
{payload.map((entry, index) => (
|
||||||
|
<p key={index} style={{ color: entry.color }}>
|
||||||
|
{entry.name}: €{Math.abs(entry.value).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate dynamic title based on time period
|
||||||
|
const getTitle = (days: number): string => {
|
||||||
|
if (days <= 30) return "Monthly Spending Trends (Last 30 Days)";
|
||||||
|
if (days <= 180) return "Monthly Spending Trends (Last 6 Months)";
|
||||||
|
if (days <= 365) return "Monthly Spending Trends (Last 12 Months)";
|
||||||
|
return `Monthly Spending Trends (Last ${Math.ceil(days / 30)} Months)`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||||
|
{getTitle(days)}
|
||||||
|
</h3>
|
||||||
|
<div className="h-80">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={displayData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="month"
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
angle={-45}
|
||||||
|
textAnchor="end"
|
||||||
|
height={60}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
tickFormatter={(value) => `€${value.toLocaleString()}`}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Bar dataKey="income" fill="#10B981" name="Income" />
|
||||||
|
<Bar dataKey="expenses" fill="#EF4444" name="Expenses" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex justify-center space-x-6 text-sm">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="w-3 h-3 bg-green-500 rounded mr-2" />
|
||||||
|
<span>Income</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="w-3 h-3 bg-red-500 rounded mr-2" />
|
||||||
|
<span>Expenses</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
frontend/src/components/analytics/StatCard.tsx
Normal file
64
frontend/src/components/analytics/StatCard.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
title: string;
|
||||||
|
value: string | number;
|
||||||
|
subtitle?: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
trend?: {
|
||||||
|
value: number;
|
||||||
|
isPositive: boolean;
|
||||||
|
};
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
subtitle,
|
||||||
|
icon: Icon,
|
||||||
|
trend,
|
||||||
|
className,
|
||||||
|
}: StatCardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"bg-white rounded-lg shadow p-6 border border-gray-200",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<Icon className="h-8 w-8 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-5 w-0 flex-1">
|
||||||
|
<dl>
|
||||||
|
<dt className="text-sm font-medium text-gray-500 truncate">
|
||||||
|
{title}
|
||||||
|
</dt>
|
||||||
|
<dd className="flex items-baseline">
|
||||||
|
<div className="text-2xl font-semibold text-gray-900">
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
{trend && (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"ml-2 flex items-baseline text-sm font-semibold",
|
||||||
|
trend.isPositive ? "text-green-600" : "text-red-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{trend.isPositive ? "+" : ""}
|
||||||
|
{trend.value}%
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
{subtitle && (
|
||||||
|
<dd className="text-sm text-gray-600 mt-1">{subtitle}</dd>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
frontend/src/components/analytics/TimePeriodFilter.tsx
Normal file
39
frontend/src/components/analytics/TimePeriodFilter.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Calendar } from "lucide-react";
|
||||||
|
import type { TimePeriod } from "../../lib/timePeriods";
|
||||||
|
import { TIME_PERIODS } from "../../lib/timePeriods";
|
||||||
|
|
||||||
|
interface TimePeriodFilterProps {
|
||||||
|
selectedPeriod: TimePeriod;
|
||||||
|
onPeriodChange: (period: TimePeriod) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TimePeriodFilter({
|
||||||
|
selectedPeriod,
|
||||||
|
onPeriodChange,
|
||||||
|
className = "",
|
||||||
|
}: TimePeriodFilterProps) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-4 ${className}`}>
|
||||||
|
<div className="flex items-center gap-2 text-gray-700">
|
||||||
|
<Calendar size={20} />
|
||||||
|
<span className="font-medium">Time Period:</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{TIME_PERIODS.map((period) => (
|
||||||
|
<button
|
||||||
|
key={period.value}
|
||||||
|
onClick={() => onPeriodChange(period)}
|
||||||
|
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||||
|
selectedPeriod.value === period.value
|
||||||
|
? "bg-blue-600 text-white"
|
||||||
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{period.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
frontend/src/components/analytics/TransactionDistribution.tsx
Normal file
142
frontend/src/components/analytics/TransactionDistribution.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import {
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
} from "recharts";
|
||||||
|
import type { Account } from "../../types/api";
|
||||||
|
|
||||||
|
interface TransactionDistributionProps {
|
||||||
|
accounts: Account[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PieDataPoint {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TooltipProps {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: Array<{
|
||||||
|
payload: PieDataPoint;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TransactionDistribution({
|
||||||
|
accounts,
|
||||||
|
className,
|
||||||
|
}: TransactionDistributionProps) {
|
||||||
|
// Helper function to get bank name from institution_id
|
||||||
|
const getBankName = (institutionId: string): string => {
|
||||||
|
const bankMapping: Record<string, string> = {
|
||||||
|
'REVOLUT_REVOLT21': 'Revolut',
|
||||||
|
'NUBANK_NUPBBR25': 'Nu Pagamentos',
|
||||||
|
'BANCOBPI_BBPIPTPL': 'Banco BPI',
|
||||||
|
// TODO: Add more bank mappings as needed
|
||||||
|
};
|
||||||
|
return bankMapping[institutionId] || institutionId.split('_')[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to create display name for account
|
||||||
|
const getAccountDisplayName = (account: Account): string => {
|
||||||
|
const bankName = getBankName(account.institution_id);
|
||||||
|
const accountName = account.name || `Account ${account.id.split('-')[1]}`;
|
||||||
|
return `${bankName} - ${accountName}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create pie chart data from account balances
|
||||||
|
const pieData: PieDataPoint[] = accounts.map((account, index) => {
|
||||||
|
const primaryBalance = account.balances?.[0]?.amount || 0;
|
||||||
|
|
||||||
|
const colors = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6"];
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: getAccountDisplayName(account),
|
||||||
|
value: primaryBalance,
|
||||||
|
color: colors[index % colors.length],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalBalance = pieData.reduce((sum, item) => sum + item.value, 0);
|
||||||
|
|
||||||
|
if (pieData.length === 0 || totalBalance === 0) {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||||
|
Account Distribution
|
||||||
|
</h3>
|
||||||
|
<div className="h-80 flex items-center justify-center text-gray-500">
|
||||||
|
No account data available
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload }: TooltipProps) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
const data = payload[0].payload;
|
||||||
|
const percentage = ((data.value / totalBalance) * 100).toFixed(1);
|
||||||
|
return (
|
||||||
|
<div className="bg-white p-3 border rounded shadow-lg">
|
||||||
|
<p className="font-medium">{data.name}</p>
|
||||||
|
<p className="text-blue-600">
|
||||||
|
Balance: €{data.value.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-600">{percentage}% of total</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||||
|
Account Balance Distribution
|
||||||
|
</h3>
|
||||||
|
<div className="h-80">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={pieData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
outerRadius={100}
|
||||||
|
innerRadius={40}
|
||||||
|
paddingAngle={2}
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{pieData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Legend
|
||||||
|
formatter={(value, entry: { color?: string }) => (
|
||||||
|
<span style={{ color: entry.color }}>{value}</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-2">
|
||||||
|
{pieData.map((item, index) => (
|
||||||
|
<div key={index} className="flex items-center justify-between text-sm">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full mr-2"
|
||||||
|
style={{ backgroundColor: item.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-gray-700">{item.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-medium">€{item.value.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
frontend/src/components/filters/AccountCombobox.tsx
Normal file
118
frontend/src/components/filters/AccountCombobox.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Check, ChevronDown, Building2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import type { Account } from "../../types/api";
|
||||||
|
|
||||||
|
export interface AccountComboboxProps {
|
||||||
|
accounts?: Account[];
|
||||||
|
selectedAccount: string;
|
||||||
|
onAccountChange: (accountId: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccountCombobox({
|
||||||
|
accounts = [],
|
||||||
|
selectedAccount,
|
||||||
|
onAccountChange,
|
||||||
|
className,
|
||||||
|
}: AccountComboboxProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const selectedAccountData = accounts.find((account) => account.id === selectedAccount);
|
||||||
|
|
||||||
|
const formatAccountName = (account: Account) => {
|
||||||
|
const displayName = account.name || "Unnamed Account";
|
||||||
|
return `${displayName} (${account.institution_id})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="justify-between"
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Building2 className="mr-2 h-4 w-4" />
|
||||||
|
{selectedAccountData
|
||||||
|
? formatAccountName(selectedAccountData)
|
||||||
|
: "All accounts"}
|
||||||
|
</div>
|
||||||
|
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[300px] p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="Search accounts..." className="h-9" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No accounts found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{/* All accounts option */}
|
||||||
|
<CommandItem
|
||||||
|
value="all-accounts"
|
||||||
|
onSelect={() => {
|
||||||
|
onAccountChange("");
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
selectedAccount === "" ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Building2 className="mr-2 h-4 w-4 text-gray-400" />
|
||||||
|
All accounts
|
||||||
|
</CommandItem>
|
||||||
|
|
||||||
|
{/* Individual accounts */}
|
||||||
|
{accounts.map((account) => (
|
||||||
|
<CommandItem
|
||||||
|
key={account.id}
|
||||||
|
value={`${account.name} ${account.institution_id}`}
|
||||||
|
onSelect={() => {
|
||||||
|
onAccountChange(account.id);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
selectedAccount === account.id
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">
|
||||||
|
{account.name || "Unnamed Account"}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{account.institution_id}
|
||||||
|
{account.iban && ` • ${account.iban.slice(-4)}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
frontend/src/components/filters/ActiveFilterChips.tsx
Normal file
134
frontend/src/components/filters/ActiveFilterChips.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { X } from "lucide-react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { formatDate } from "@/lib/utils";
|
||||||
|
import type { FilterState } from "./FilterBar";
|
||||||
|
import type { Account } from "../../types/api";
|
||||||
|
|
||||||
|
export interface ActiveFilterChipsProps {
|
||||||
|
filterState: FilterState;
|
||||||
|
onFilterChange: (key: keyof FilterState, value: string) => void;
|
||||||
|
accounts?: Account[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActiveFilterChips({
|
||||||
|
filterState,
|
||||||
|
onFilterChange,
|
||||||
|
accounts = [],
|
||||||
|
}: ActiveFilterChipsProps) {
|
||||||
|
const chips: Array<{
|
||||||
|
key: keyof FilterState;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// Search term chip
|
||||||
|
if (filterState.searchTerm) {
|
||||||
|
chips.push({
|
||||||
|
key: "searchTerm",
|
||||||
|
label: `Search: "${filterState.searchTerm}"`,
|
||||||
|
value: filterState.searchTerm,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Account chip
|
||||||
|
if (filterState.selectedAccount) {
|
||||||
|
const account = accounts.find((acc) => acc.id === filterState.selectedAccount);
|
||||||
|
const accountName = account
|
||||||
|
? `${account.name || "Unnamed Account"} (${account.institution_id})`
|
||||||
|
: "Unknown Account";
|
||||||
|
chips.push({
|
||||||
|
key: "selectedAccount",
|
||||||
|
label: accountName,
|
||||||
|
value: filterState.selectedAccount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date range chip
|
||||||
|
if (filterState.startDate || filterState.endDate) {
|
||||||
|
let dateLabel = "Date: ";
|
||||||
|
if (filterState.startDate && filterState.endDate) {
|
||||||
|
if (filterState.startDate === filterState.endDate) {
|
||||||
|
dateLabel += formatDate(filterState.startDate);
|
||||||
|
} else {
|
||||||
|
dateLabel += `${formatDate(filterState.startDate)} - ${formatDate(filterState.endDate)}`;
|
||||||
|
}
|
||||||
|
} else if (filterState.startDate) {
|
||||||
|
dateLabel += `From ${formatDate(filterState.startDate)}`;
|
||||||
|
} else if (filterState.endDate) {
|
||||||
|
dateLabel += `Until ${formatDate(filterState.endDate)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
chips.push({
|
||||||
|
key: "startDate", // We'll clear both start and end date when removing this chip
|
||||||
|
label: dateLabel,
|
||||||
|
value: `${filterState.startDate}-${filterState.endDate}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Amount range chips
|
||||||
|
if (filterState.minAmount || filterState.maxAmount) {
|
||||||
|
let amountLabel = "Amount: ";
|
||||||
|
const minAmount = filterState.minAmount ? parseFloat(filterState.minAmount) : null;
|
||||||
|
const maxAmount = filterState.maxAmount ? parseFloat(filterState.maxAmount) : null;
|
||||||
|
|
||||||
|
if (minAmount && maxAmount) {
|
||||||
|
amountLabel += `€${minAmount} - €${maxAmount}`;
|
||||||
|
} else if (minAmount) {
|
||||||
|
amountLabel += `≥ €${minAmount}`;
|
||||||
|
} else if (maxAmount) {
|
||||||
|
amountLabel += `≤ €${maxAmount}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
chips.push({
|
||||||
|
key: "minAmount", // We'll clear both min and max when removing this chip
|
||||||
|
label: amountLabel,
|
||||||
|
value: `${filterState.minAmount}-${filterState.maxAmount}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveChip = (key: keyof FilterState) => {
|
||||||
|
switch (key) {
|
||||||
|
case "startDate":
|
||||||
|
// Clear both start and end date
|
||||||
|
onFilterChange("startDate", "");
|
||||||
|
onFilterChange("endDate", "");
|
||||||
|
break;
|
||||||
|
case "minAmount":
|
||||||
|
// Clear both min and max amount
|
||||||
|
onFilterChange("minAmount", "");
|
||||||
|
onFilterChange("maxAmount", "");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
onFilterChange(key, "");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (chips.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm text-gray-600 font-medium">Active filters:</span>
|
||||||
|
{chips.map((chip) => (
|
||||||
|
<Badge
|
||||||
|
key={`${chip.key}-${chip.value}`}
|
||||||
|
variant="secondary"
|
||||||
|
className="pl-3 pr-1 py-1 bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100"
|
||||||
|
>
|
||||||
|
<span className="mr-1 text-xs">{chip.label}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-4 w-4 p-0 hover:bg-blue-200/50"
|
||||||
|
onClick={() => handleRemoveChip(chip.key)}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
<span className="sr-only">Remove {chip.label} filter</span>
|
||||||
|
</Button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
frontend/src/components/filters/AdvancedFiltersPopover.tsx
Normal file
122
frontend/src/components/filters/AdvancedFiltersPopover.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { MoreHorizontal, Euro } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
|
||||||
|
export interface AdvancedFiltersPopoverProps {
|
||||||
|
minAmount: string;
|
||||||
|
maxAmount: string;
|
||||||
|
onMinAmountChange: (value: string) => void;
|
||||||
|
onMaxAmountChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdvancedFiltersPopover({
|
||||||
|
minAmount,
|
||||||
|
maxAmount,
|
||||||
|
onMinAmountChange,
|
||||||
|
onMaxAmountChange,
|
||||||
|
}: AdvancedFiltersPopoverProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const hasAdvancedFilters = minAmount || maxAmount;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant={hasAdvancedFilters ? "default" : "outline"}
|
||||||
|
size="default"
|
||||||
|
className="relative"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4 mr-2" />
|
||||||
|
More
|
||||||
|
{hasAdvancedFilters && (
|
||||||
|
<div className="absolute -top-1 -right-1 h-2 w-2 bg-blue-600 rounded-full" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-80" align="end">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-medium leading-none">Advanced Filters</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Additional filters for more precise results
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||||
|
Amount Range
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-muted-foreground">
|
||||||
|
Minimum
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Euro className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="0.00"
|
||||||
|
value={minAmount}
|
||||||
|
onChange={(e) => onMinAmountChange(e.target.value)}
|
||||||
|
className="pl-8"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-muted-foreground">
|
||||||
|
Maximum
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Euro className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="1000.00"
|
||||||
|
value={maxAmount}
|
||||||
|
onChange={(e) => onMaxAmountChange(e.target.value)}
|
||||||
|
className="pl-8"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Leave empty for no limit
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Future: Add transaction status filter */}
|
||||||
|
<div className="pt-2 border-t">
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
More filters coming soon: transaction status, categories, and more.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Clear advanced filters */}
|
||||||
|
{hasAdvancedFilters && (
|
||||||
|
<div className="pt-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
onMinAmountChange("");
|
||||||
|
onMaxAmountChange("");
|
||||||
|
}}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Clear Advanced Filters
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
207
frontend/src/components/filters/DateRangePicker.tsx
Normal file
207
frontend/src/components/filters/DateRangePicker.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { Calendar as CalendarIcon, ChevronDown } from "lucide-react";
|
||||||
|
import type { DateRange } from "react-day-picker";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Calendar } from "@/components/ui/calendar";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
|
||||||
|
export interface DateRangePickerProps {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
onDateRangeChange: (startDate: string, endDate: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DatePreset {
|
||||||
|
label: string;
|
||||||
|
getValue: () => { startDate: string; endDate: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const datePresets: DatePreset[] = [
|
||||||
|
{
|
||||||
|
label: "Last 7 days",
|
||||||
|
getValue: () => {
|
||||||
|
const endDate = new Date();
|
||||||
|
const startDate = new Date();
|
||||||
|
startDate.setDate(endDate.getDate() - 7);
|
||||||
|
return {
|
||||||
|
startDate: startDate.toISOString().split("T")[0],
|
||||||
|
endDate: endDate.toISOString().split("T")[0],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "This week",
|
||||||
|
getValue: () => {
|
||||||
|
const now = new Date();
|
||||||
|
const dayOfWeek = now.getDay();
|
||||||
|
const startOfWeek = new Date(now);
|
||||||
|
startOfWeek.setDate(now.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1)); // Monday as start
|
||||||
|
|
||||||
|
const endOfWeek = new Date(startOfWeek);
|
||||||
|
endOfWeek.setDate(startOfWeek.getDate() + 6);
|
||||||
|
|
||||||
|
return {
|
||||||
|
startDate: startOfWeek.toISOString().split("T")[0],
|
||||||
|
endDate: endOfWeek.toISOString().split("T")[0],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Last 30 days",
|
||||||
|
getValue: () => {
|
||||||
|
const endDate = new Date();
|
||||||
|
const startDate = new Date();
|
||||||
|
startDate.setDate(endDate.getDate() - 30);
|
||||||
|
return {
|
||||||
|
startDate: startDate.toISOString().split("T")[0],
|
||||||
|
endDate: endDate.toISOString().split("T")[0],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "This month",
|
||||||
|
getValue: () => {
|
||||||
|
const now = new Date();
|
||||||
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
startDate: startOfMonth.toISOString().split("T")[0],
|
||||||
|
endDate: endOfMonth.toISOString().split("T")[0],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "This year",
|
||||||
|
getValue: () => {
|
||||||
|
const now = new Date();
|
||||||
|
const startOfYear = new Date(now.getFullYear(), 0, 1);
|
||||||
|
const endOfYear = new Date(now.getFullYear(), 11, 31);
|
||||||
|
|
||||||
|
return {
|
||||||
|
startDate: startOfYear.toISOString().split("T")[0],
|
||||||
|
endDate: endOfYear.toISOString().split("T")[0],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DateRangePicker({
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
onDateRangeChange,
|
||||||
|
className,
|
||||||
|
}: DateRangePickerProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
// Convert string dates to Date objects for the calendar
|
||||||
|
const dateRange: DateRange | undefined =
|
||||||
|
startDate && endDate
|
||||||
|
? {
|
||||||
|
from: new Date(startDate),
|
||||||
|
to: new Date(endDate),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const handleDateRangeSelect = (range: DateRange | undefined) => {
|
||||||
|
if (range?.from && range?.to) {
|
||||||
|
onDateRangeChange(
|
||||||
|
range.from.toISOString().split("T")[0],
|
||||||
|
range.to.toISOString().split("T")[0]
|
||||||
|
);
|
||||||
|
} else if (range?.from && !range?.to) {
|
||||||
|
onDateRangeChange(
|
||||||
|
range.from.toISOString().split("T")[0],
|
||||||
|
range.from.toISOString().split("T")[0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePresetClick = (preset: DatePreset) => {
|
||||||
|
const { startDate: presetStart, endDate: presetEnd } = preset.getValue();
|
||||||
|
onDateRangeChange(presetStart, presetEnd);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateRange = () => {
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
return "Select date range";
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = new Date(startDate);
|
||||||
|
const end = new Date(endDate);
|
||||||
|
|
||||||
|
// Check if it matches a preset
|
||||||
|
const matchingPreset = datePresets.find((preset) => {
|
||||||
|
const { startDate: presetStart, endDate: presetEnd } = preset.getValue();
|
||||||
|
return presetStart === startDate && presetEnd === endDate;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matchingPreset) {
|
||||||
|
return matchingPreset.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format custom range
|
||||||
|
if (startDate === endDate) {
|
||||||
|
return format(start, "MMM d, yyyy");
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${format(start, "MMM d")} - ${format(end, "MMM d, yyyy")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("grid gap-2", className)}>
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"justify-between text-left font-normal",
|
||||||
|
!dateRange && "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
|
{formatDateRange()}
|
||||||
|
</div>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
|
<div className="flex">
|
||||||
|
{/* Presets */}
|
||||||
|
<div className="border-r p-3 space-y-1">
|
||||||
|
<div className="text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Quick select
|
||||||
|
</div>
|
||||||
|
{datePresets.map((preset) => (
|
||||||
|
<Button
|
||||||
|
key={preset.label}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start text-sm"
|
||||||
|
onClick={() => handlePresetClick(preset)}
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Calendar */}
|
||||||
|
<Calendar
|
||||||
|
initialFocus
|
||||||
|
mode="range"
|
||||||
|
defaultMonth={dateRange?.from}
|
||||||
|
selected={dateRange}
|
||||||
|
onSelect={handleDateRangeSelect}
|
||||||
|
numberOfMonths={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
frontend/src/components/filters/FilterBar.tsx
Normal file
139
frontend/src/components/filters/FilterBar.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { Search, X } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { DateRangePicker } from "./DateRangePicker";
|
||||||
|
import { AccountCombobox } from "./AccountCombobox";
|
||||||
|
import { ActiveFilterChips } from "./ActiveFilterChips";
|
||||||
|
import { AdvancedFiltersPopover } from "./AdvancedFiltersPopover";
|
||||||
|
import type { Account } from "../../types/api";
|
||||||
|
|
||||||
|
export interface FilterState {
|
||||||
|
searchTerm: string;
|
||||||
|
selectedAccount: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
minAmount: string;
|
||||||
|
maxAmount: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterBarProps {
|
||||||
|
filterState: FilterState;
|
||||||
|
onFilterChange: (key: keyof FilterState, value: string) => void;
|
||||||
|
onClearFilters: () => void;
|
||||||
|
accounts?: Account[];
|
||||||
|
isSearchLoading?: boolean;
|
||||||
|
showRunningBalance: boolean;
|
||||||
|
onToggleRunningBalance: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterBar({
|
||||||
|
filterState,
|
||||||
|
onFilterChange,
|
||||||
|
onClearFilters,
|
||||||
|
accounts,
|
||||||
|
isSearchLoading = false,
|
||||||
|
showRunningBalance,
|
||||||
|
onToggleRunningBalance,
|
||||||
|
className,
|
||||||
|
}: FilterBarProps) {
|
||||||
|
|
||||||
|
const hasActiveFilters =
|
||||||
|
filterState.searchTerm ||
|
||||||
|
filterState.selectedAccount ||
|
||||||
|
filterState.startDate ||
|
||||||
|
filterState.endDate ||
|
||||||
|
filterState.minAmount ||
|
||||||
|
filterState.maxAmount;
|
||||||
|
|
||||||
|
const handleDateRangeChange = (startDate: string, endDate: string) => {
|
||||||
|
onFilterChange("startDate", startDate);
|
||||||
|
onFilterChange("endDate", endDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("bg-white rounded-lg shadow border", className)}>
|
||||||
|
{/* Main Filter Bar */}
|
||||||
|
<div className="px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">Transactions</h3>
|
||||||
|
<Button
|
||||||
|
onClick={onToggleRunningBalance}
|
||||||
|
variant={showRunningBalance ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Balance
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Primary Filters Row */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||||
|
{/* Search Input */}
|
||||||
|
<div className="relative flex-1 min-w-[240px]">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search transactions..."
|
||||||
|
value={filterState.searchTerm}
|
||||||
|
onChange={(e) => onFilterChange("searchTerm", e.target.value)}
|
||||||
|
className="pl-9 pr-8"
|
||||||
|
/>
|
||||||
|
{isSearchLoading && (
|
||||||
|
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
|
||||||
|
<div className="animate-spin h-4 w-4 border-2 border-gray-300 border-t-blue-500 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Account Selection */}
|
||||||
|
<AccountCombobox
|
||||||
|
accounts={accounts}
|
||||||
|
selectedAccount={filterState.selectedAccount}
|
||||||
|
onAccountChange={(accountId) =>
|
||||||
|
onFilterChange("selectedAccount", accountId)
|
||||||
|
}
|
||||||
|
className="w-[200px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Date Range Picker */}
|
||||||
|
<DateRangePicker
|
||||||
|
startDate={filterState.startDate}
|
||||||
|
endDate={filterState.endDate}
|
||||||
|
onDateRangeChange={handleDateRangeChange}
|
||||||
|
className="w-[240px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Advanced Filters Button */}
|
||||||
|
<AdvancedFiltersPopover
|
||||||
|
minAmount={filterState.minAmount}
|
||||||
|
maxAmount={filterState.maxAmount}
|
||||||
|
onMinAmountChange={(value) => onFilterChange("minAmount", value)}
|
||||||
|
onMaxAmountChange={(value) => onFilterChange("maxAmount", value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Clear Filters Button */}
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<Button
|
||||||
|
onClick={onClearFilters}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-gray-600"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4 mr-1" />
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Filter Chips */}
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<ActiveFilterChips
|
||||||
|
filterState={filterState}
|
||||||
|
onFilterChange={onFilterChange}
|
||||||
|
accounts={accounts}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
frontend/src/components/filters/index.ts
Normal file
6
frontend/src/components/filters/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export { FilterBar } from './FilterBar';
|
||||||
|
export { DateRangePicker } from './DateRangePicker';
|
||||||
|
export { AccountCombobox } from './AccountCombobox';
|
||||||
|
export { ActiveFilterChips } from './ActiveFilterChips';
|
||||||
|
export { AdvancedFiltersPopover } from './AdvancedFiltersPopover';
|
||||||
|
export type { FilterState, FilterBarProps } from './FilterBar';
|
||||||
36
frontend/src/components/ui/badge.tsx
Normal file
36
frontend/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||||
|
outline: "text-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
57
frontend/src/components/ui/button.tsx
Normal file
57
frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2",
|
||||||
|
sm: "h-8 rounded-md px-3 text-xs",
|
||||||
|
lg: "h-10 rounded-md px-8",
|
||||||
|
icon: "h-9 w-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
211
frontend/src/components/ui/calendar.tsx
Normal file
211
frontend/src/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
captionLayout = "label",
|
||||||
|
buttonVariant = "ghost",
|
||||||
|
formatters,
|
||||||
|
components,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayPicker> & {
|
||||||
|
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||||
|
}) {
|
||||||
|
const defaultClassNames = getDefaultClassNames()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn(
|
||||||
|
"bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||||
|
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||||
|
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
captionLayout={captionLayout}
|
||||||
|
formatters={{
|
||||||
|
formatMonthDropdown: (date) =>
|
||||||
|
date.toLocaleString("default", { month: "short" }),
|
||||||
|
...formatters,
|
||||||
|
}}
|
||||||
|
classNames={{
|
||||||
|
root: cn("w-fit", defaultClassNames.root),
|
||||||
|
months: cn(
|
||||||
|
"relative flex flex-col gap-4 md:flex-row",
|
||||||
|
defaultClassNames.months
|
||||||
|
),
|
||||||
|
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
|
||||||
|
nav: cn(
|
||||||
|
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
|
||||||
|
defaultClassNames.nav
|
||||||
|
),
|
||||||
|
button_previous: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
|
||||||
|
defaultClassNames.button_previous
|
||||||
|
),
|
||||||
|
button_next: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
|
||||||
|
defaultClassNames.button_next
|
||||||
|
),
|
||||||
|
month_caption: cn(
|
||||||
|
"flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]",
|
||||||
|
defaultClassNames.month_caption
|
||||||
|
),
|
||||||
|
dropdowns: cn(
|
||||||
|
"flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||||
|
defaultClassNames.dropdowns
|
||||||
|
),
|
||||||
|
dropdown_root: cn(
|
||||||
|
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
|
||||||
|
defaultClassNames.dropdown_root
|
||||||
|
),
|
||||||
|
dropdown: cn(
|
||||||
|
"bg-popover absolute inset-0 opacity-0",
|
||||||
|
defaultClassNames.dropdown
|
||||||
|
),
|
||||||
|
caption_label: cn(
|
||||||
|
"select-none font-medium",
|
||||||
|
captionLayout === "label"
|
||||||
|
? "text-sm"
|
||||||
|
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
|
||||||
|
defaultClassNames.caption_label
|
||||||
|
),
|
||||||
|
table: "w-full border-collapse",
|
||||||
|
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||||
|
weekday: cn(
|
||||||
|
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
|
||||||
|
defaultClassNames.weekday
|
||||||
|
),
|
||||||
|
week: cn("mt-2 flex w-full", defaultClassNames.week),
|
||||||
|
week_number_header: cn(
|
||||||
|
"w-[--cell-size] select-none",
|
||||||
|
defaultClassNames.week_number_header
|
||||||
|
),
|
||||||
|
week_number: cn(
|
||||||
|
"text-muted-foreground select-none text-[0.8rem]",
|
||||||
|
defaultClassNames.week_number
|
||||||
|
),
|
||||||
|
day: cn(
|
||||||
|
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
|
||||||
|
defaultClassNames.day
|
||||||
|
),
|
||||||
|
range_start: cn(
|
||||||
|
"bg-accent rounded-l-md",
|
||||||
|
defaultClassNames.range_start
|
||||||
|
),
|
||||||
|
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||||
|
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
|
||||||
|
today: cn(
|
||||||
|
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||||
|
defaultClassNames.today
|
||||||
|
),
|
||||||
|
outside: cn(
|
||||||
|
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||||
|
defaultClassNames.outside
|
||||||
|
),
|
||||||
|
disabled: cn(
|
||||||
|
"text-muted-foreground opacity-50",
|
||||||
|
defaultClassNames.disabled
|
||||||
|
),
|
||||||
|
hidden: cn("invisible", defaultClassNames.hidden),
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
Root: ({ className, rootRef, ...props }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="calendar"
|
||||||
|
ref={rootRef}
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
Chevron: ({ className, orientation, ...props }) => {
|
||||||
|
if (orientation === "left") {
|
||||||
|
return (
|
||||||
|
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orientation === "right") {
|
||||||
|
return (
|
||||||
|
<ChevronRightIcon
|
||||||
|
className={cn("size-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
},
|
||||||
|
DayButton: CalendarDayButton,
|
||||||
|
WeekNumber: ({ children, ...props }) => {
|
||||||
|
return (
|
||||||
|
<td {...props}>
|
||||||
|
<div className="flex size-[--cell-size] items-center justify-center text-center">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
...components,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarDayButton({
|
||||||
|
className,
|
||||||
|
day,
|
||||||
|
modifiers,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayButton>) {
|
||||||
|
const defaultClassNames = getDefaultClassNames()
|
||||||
|
|
||||||
|
const ref = React.useRef<HTMLButtonElement>(null)
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (modifiers.focused) ref.current?.focus()
|
||||||
|
}, [modifiers.focused])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
data-day={day.date.toLocaleDateString()}
|
||||||
|
data-selected-single={
|
||||||
|
modifiers.selected &&
|
||||||
|
!modifiers.range_start &&
|
||||||
|
!modifiers.range_end &&
|
||||||
|
!modifiers.range_middle
|
||||||
|
}
|
||||||
|
data-range-start={modifiers.range_start}
|
||||||
|
data-range-end={modifiers.range_end}
|
||||||
|
data-range-middle={modifiers.range_middle}
|
||||||
|
className={cn(
|
||||||
|
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
|
||||||
|
defaultClassNames.day,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Calendar, CalendarDayButton }
|
||||||
153
frontend/src/components/ui/command.tsx
Normal file
153
frontend/src/components/ui/command.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { type DialogProps } from "@radix-ui/react-dialog"
|
||||||
|
import { Command as CommandPrimitive } from "cmdk"
|
||||||
|
import { Search } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
||||||
|
|
||||||
|
const Command = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Command.displayName = CommandPrimitive.displayName
|
||||||
|
|
||||||
|
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogContent className="overflow-hidden p-0">
|
||||||
|
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
|
{children}
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const CommandInput = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||||
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||||
|
|
||||||
|
const CommandList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandList.displayName = CommandPrimitive.List.displayName
|
||||||
|
|
||||||
|
const CommandEmpty = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||||
|
>((props, ref) => (
|
||||||
|
<CommandPrimitive.Empty
|
||||||
|
ref={ref}
|
||||||
|
className="py-6 text-center text-sm"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||||
|
|
||||||
|
const CommandGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||||
|
|
||||||
|
const CommandSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const CommandItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const CommandShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CommandShortcut.displayName = "CommandShortcut"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
}
|
||||||
120
frontend/src/components/ui/dialog.tsx
Normal file
120
frontend/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root
|
||||||
|
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger
|
||||||
|
|
||||||
|
const DialogPortal = DialogPrimitive.Portal
|
||||||
|
|
||||||
|
const DialogClose = DialogPrimitive.Close
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
))
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogHeader.displayName = "DialogHeader"
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogFooter.displayName = "DialogFooter"
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
}
|
||||||
22
frontend/src/components/ui/input.tsx
Normal file
22
frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input }
|
||||||
31
frontend/src/components/ui/popover.tsx
Normal file
31
frontend/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Popover = PopoverPrimitive.Root
|
||||||
|
|
||||||
|
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||||
|
|
||||||
|
const PopoverAnchor = PopoverPrimitive.Anchor
|
||||||
|
|
||||||
|
const PopoverContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
|
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
))
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||||
157
frontend/src/components/ui/select.tsx
Normal file
157
frontend/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
))
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
))
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
))
|
||||||
|
SelectScrollDownButton.displayName =
|
||||||
|
SelectPrimitive.ScrollDownButton.displayName
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
))
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
))
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
}
|
||||||
@@ -1,3 +1,68 @@
|
|||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
|
--primary: 222.2 47.4% 11.2%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--secondary: 210 40% 96.1%;
|
||||||
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--muted: 210 40% 96.1%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
--accent: 210 40% 96.1%;
|
||||||
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
--ring: 222.2 84% 4.9%;
|
||||||
|
--chart-1: 12 76% 61%;
|
||||||
|
--chart-2: 173 58% 39%;
|
||||||
|
--chart-3: 197 37% 24%;
|
||||||
|
--chart-4: 43 74% 66%;
|
||||||
|
--chart-5: 27 87% 67%;
|
||||||
|
--radius: 0.5rem
|
||||||
|
}
|
||||||
|
.dark {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
--card: 222.2 84% 4.9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
--popover: 222.2 84% 4.9%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
--primary: 210 40% 98%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--ring: 212.7 26.8% 83.9%;
|
||||||
|
--chart-1: 220 70% 50%;
|
||||||
|
--chart-2: 160 60% 45%;
|
||||||
|
--chart-3: 30 80% 55%;
|
||||||
|
--chart-4: 280 65% 60%;
|
||||||
|
--chart-5: 340 75% 55%
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import axios from "axios";
|
|||||||
import type {
|
import type {
|
||||||
Account,
|
Account,
|
||||||
Transaction,
|
Transaction,
|
||||||
|
AnalyticsTransaction,
|
||||||
Balance,
|
Balance,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
NotificationSettings,
|
NotificationSettings,
|
||||||
@@ -10,6 +11,7 @@ import type {
|
|||||||
NotificationServicesResponse,
|
NotificationServicesResponse,
|
||||||
HealthData,
|
HealthData,
|
||||||
AccountUpdate,
|
AccountUpdate,
|
||||||
|
TransactionStats,
|
||||||
} from "../types/api";
|
} from "../types/api";
|
||||||
|
|
||||||
// Use VITE_API_URL for development, relative URLs for production
|
// Use VITE_API_URL for development, relative URLs for production
|
||||||
@@ -53,6 +55,18 @@ export const apiClient = {
|
|||||||
return response.data.data;
|
return response.data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Get historical balances for balance progression chart
|
||||||
|
getHistoricalBalances: async (days?: number, accountId?: string): Promise<Balance[]> => {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
if (days) queryParams.append("days", days.toString());
|
||||||
|
if (accountId) queryParams.append("account_id", accountId);
|
||||||
|
|
||||||
|
const response = await api.get<ApiResponse<Balance[]>>(
|
||||||
|
`/balances/history?${queryParams.toString()}`
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
// Get balances for specific account
|
// Get balances for specific account
|
||||||
getAccountBalances: async (accountId: string): Promise<Balance[]> => {
|
getAccountBalances: async (accountId: string): Promise<Balance[]> => {
|
||||||
const response = await api.get<ApiResponse<Balance[]>>(
|
const response = await api.get<ApiResponse<Balance[]>>(
|
||||||
@@ -70,12 +84,14 @@ export const apiClient = {
|
|||||||
perPage?: number;
|
perPage?: number;
|
||||||
search?: string;
|
search?: string;
|
||||||
summaryOnly?: boolean;
|
summaryOnly?: boolean;
|
||||||
}): Promise<Transaction[]> => {
|
minAmount?: number;
|
||||||
|
maxAmount?: number;
|
||||||
|
}): Promise<ApiResponse<Transaction[]>> => {
|
||||||
const queryParams = new URLSearchParams();
|
const queryParams = new URLSearchParams();
|
||||||
|
|
||||||
if (params?.accountId) queryParams.append("account_id", params.accountId);
|
if (params?.accountId) queryParams.append("account_id", params.accountId);
|
||||||
if (params?.startDate) queryParams.append("start_date", params.startDate);
|
if (params?.startDate) queryParams.append("date_from", params.startDate);
|
||||||
if (params?.endDate) queryParams.append("end_date", params.endDate);
|
if (params?.endDate) queryParams.append("date_to", params.endDate);
|
||||||
if (params?.page) queryParams.append("page", params.page.toString());
|
if (params?.page) queryParams.append("page", params.page.toString());
|
||||||
if (params?.perPage)
|
if (params?.perPage)
|
||||||
queryParams.append("per_page", params.perPage.toString());
|
queryParams.append("per_page", params.perPage.toString());
|
||||||
@@ -83,11 +99,17 @@ export const apiClient = {
|
|||||||
if (params?.summaryOnly !== undefined) {
|
if (params?.summaryOnly !== undefined) {
|
||||||
queryParams.append("summary_only", params.summaryOnly.toString());
|
queryParams.append("summary_only", params.summaryOnly.toString());
|
||||||
}
|
}
|
||||||
|
if (params?.minAmount !== undefined) {
|
||||||
|
queryParams.append("min_amount", params.minAmount.toString());
|
||||||
|
}
|
||||||
|
if (params?.maxAmount !== undefined) {
|
||||||
|
queryParams.append("max_amount", params.maxAmount.toString());
|
||||||
|
}
|
||||||
|
|
||||||
const response = await api.get<ApiResponse<Transaction[]>>(
|
const response = await api.get<ApiResponse<Transaction[]>>(
|
||||||
`/transactions?${queryParams.toString()}`,
|
`/transactions?${queryParams.toString()}`,
|
||||||
);
|
);
|
||||||
return response.data.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get transaction by ID
|
// Get transaction by ID
|
||||||
@@ -142,6 +164,49 @@ export const apiClient = {
|
|||||||
const response = await api.get<ApiResponse<HealthData>>("/health");
|
const response = await api.get<ApiResponse<HealthData>>("/health");
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Analytics endpoints
|
||||||
|
getTransactionStats: async (days?: number): Promise<TransactionStats> => {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
if (days) queryParams.append("days", days.toString());
|
||||||
|
|
||||||
|
const response = await api.get<ApiResponse<TransactionStats>>(
|
||||||
|
`/transactions/stats?${queryParams.toString()}`
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get all transactions for analytics (no pagination)
|
||||||
|
getTransactionsForAnalytics: async (days?: number): Promise<AnalyticsTransaction[]> => {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
if (days) queryParams.append("days", days.toString());
|
||||||
|
|
||||||
|
const response = await api.get<ApiResponse<AnalyticsTransaction[]>>(
|
||||||
|
`/transactions/analytics?${queryParams.toString()}`
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get monthly transaction statistics (pre-calculated)
|
||||||
|
getMonthlyTransactionStats: async (days?: number): Promise<Array<{
|
||||||
|
month: string;
|
||||||
|
income: number;
|
||||||
|
expenses: number;
|
||||||
|
net: number;
|
||||||
|
}>> => {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
if (days) queryParams.append("days", days.toString());
|
||||||
|
|
||||||
|
const response = await api.get<ApiResponse<Array<{
|
||||||
|
month: string;
|
||||||
|
income: number;
|
||||||
|
expenses: number;
|
||||||
|
net: number;
|
||||||
|
}>>>(
|
||||||
|
`/transactions/monthly-stats?${queryParams.toString()}`
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default apiClient;
|
export default apiClient;
|
||||||
|
|||||||
19
frontend/src/lib/timePeriods.ts
Normal file
19
frontend/src/lib/timePeriods.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export type TimePeriod = {
|
||||||
|
label: string;
|
||||||
|
days: number;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getDaysFromYearStart(): number {
|
||||||
|
const now = new Date();
|
||||||
|
const yearStart = new Date(now.getFullYear(), 0, 1);
|
||||||
|
const diffTime = now.getTime() - yearStart.getTime();
|
||||||
|
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TIME_PERIODS: TimePeriod[] = [
|
||||||
|
{ label: "Last 30 days", days: 30, value: "30d" },
|
||||||
|
{ label: "Last 6 months", days: 180, value: "6m" },
|
||||||
|
{ label: "Year to Date", days: getDaysFromYearStart(), value: "ytd" },
|
||||||
|
{ label: "Last 365 days", days: 365, value: "365d" },
|
||||||
|
];
|
||||||
@@ -1,62 +1,22 @@
|
|||||||
import { clsx, type ClassValue } from "clsx";
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return clsx(inputs);
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatCurrency(
|
export function formatCurrency(amount: number, currency: string = "EUR"): string {
|
||||||
amount: number,
|
return new Intl.NumberFormat("en-US", {
|
||||||
currency: string = "EUR",
|
style: "currency",
|
||||||
): string {
|
currency,
|
||||||
// Validate currency code - must be 3 letters and a valid ISO 4217 code
|
}).format(amount);
|
||||||
const validCurrency =
|
|
||||||
currency && /^[A-Z]{3}$/.test(currency) ? currency : "EUR";
|
|
||||||
|
|
||||||
try {
|
|
||||||
return new Intl.NumberFormat("en-US", {
|
|
||||||
style: "currency",
|
|
||||||
currency: validCurrency,
|
|
||||||
}).format(amount);
|
|
||||||
} catch {
|
|
||||||
// Fallback if currency is still invalid
|
|
||||||
console.warn(`Invalid currency code: ${currency}, falling back to EUR`);
|
|
||||||
return new Intl.NumberFormat("en-US", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "EUR",
|
|
||||||
}).format(amount);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDate(date: string): string {
|
export function formatDate(dateString: string): string {
|
||||||
if (!date) return "No date";
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString("en-US", {
|
||||||
const parsedDate = new Date(date);
|
|
||||||
if (isNaN(parsedDate.getTime())) {
|
|
||||||
console.warn("Invalid date string:", date);
|
|
||||||
return "Invalid date";
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
}).format(parsedDate);
|
});
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDateTime(date: string): string {
|
|
||||||
if (!date) return "No date";
|
|
||||||
|
|
||||||
const parsedDate = new Date(date);
|
|
||||||
if (isNaN(parsedDate.getTime())) {
|
|
||||||
console.warn("Invalid date string:", date);
|
|
||||||
return "Invalid date";
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
}).format(parsedDate);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,29 +3,31 @@ import { useState } from "react";
|
|||||||
import Sidebar from "../components/Sidebar";
|
import Sidebar from "../components/Sidebar";
|
||||||
import Header from "../components/Header";
|
import Header from "../components/Header";
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
function RootLayout() {
|
||||||
component: () => {
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-gray-100">
|
<div className="flex h-screen bg-gray-100">
|
||||||
<Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />
|
<Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />
|
||||||
|
|
||||||
{/* Mobile overlay */}
|
{/* Mobile overlay */}
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-40 bg-gray-600 bg-opacity-75 lg:hidden"
|
className="fixed inset-0 z-40 bg-gray-600 bg-opacity-75 lg:hidden"
|
||||||
onClick={() => setSidebarOpen(false)}
|
onClick={() => setSidebarOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col flex-1 overflow-hidden">
|
<div className="flex flex-col flex-1 overflow-hidden">
|
||||||
<Header setSidebarOpen={setSidebarOpen} />
|
<Header setSidebarOpen={setSidebarOpen} />
|
||||||
<main className="flex-1 overflow-y-auto p-6">
|
<main className="flex-1 overflow-y-auto p-6">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
},
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createRootRoute({
|
||||||
|
component: RootLayout,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,141 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
CreditCard,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
Activity,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import apiClient from "../lib/api";
|
||||||
|
import StatCard from "../components/analytics/StatCard";
|
||||||
|
import BalanceChart from "../components/analytics/BalanceChart";
|
||||||
|
import TransactionDistribution from "../components/analytics/TransactionDistribution";
|
||||||
|
import MonthlyTrends from "../components/analytics/MonthlyTrends";
|
||||||
|
import TimePeriodFilter from "../components/analytics/TimePeriodFilter";
|
||||||
|
import type { TimePeriod } from "../lib/timePeriods";
|
||||||
|
import { TIME_PERIODS } from "../lib/timePeriods";
|
||||||
|
|
||||||
|
function AnalyticsDashboard() {
|
||||||
|
// Default to Last 365 days
|
||||||
|
const [selectedPeriod, setSelectedPeriod] = useState<TimePeriod>(
|
||||||
|
TIME_PERIODS.find((p) => p.value === "365d") || TIME_PERIODS[3]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch analytics data
|
||||||
|
const { data: stats, isLoading: statsLoading } = useQuery({
|
||||||
|
queryKey: ["transaction-stats", selectedPeriod.days],
|
||||||
|
queryFn: () => apiClient.getTransactionStats(selectedPeriod.days),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: accounts, isLoading: accountsLoading } = useQuery({
|
||||||
|
queryKey: ["accounts"],
|
||||||
|
queryFn: () => apiClient.getAccounts(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: balances, isLoading: balancesLoading } = useQuery({
|
||||||
|
queryKey: ["historical-balances", selectedPeriod.days],
|
||||||
|
queryFn: () => apiClient.getHistoricalBalances(selectedPeriod.days),
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLoading = statsLoading || accountsLoading || balancesLoading;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="h-8 bg-gray-200 rounded w-48 mb-6"></div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<div key={i} className="h-32 bg-gray-200 rounded"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
<div className="h-96 bg-gray-200 rounded"></div>
|
||||||
|
<div className="h-96 bg-gray-200 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-8">
|
||||||
|
{/* Time Period Filter */}
|
||||||
|
<TimePeriodFilter
|
||||||
|
selectedPeriod={selectedPeriod}
|
||||||
|
onPeriodChange={setSelectedPeriod}
|
||||||
|
className="bg-white rounded-lg shadow p-4 border border-gray-200"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<StatCard
|
||||||
|
title="Total Transactions"
|
||||||
|
value={stats?.total_transactions || 0}
|
||||||
|
subtitle={`Last ${stats?.period_days || 0} days`}
|
||||||
|
icon={Activity}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Total Income"
|
||||||
|
value={`€${(stats?.total_income || 0).toLocaleString()}`}
|
||||||
|
subtitle="Inflows this period"
|
||||||
|
icon={TrendingUp}
|
||||||
|
className="border-green-200"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Total Expenses"
|
||||||
|
value={`€${(stats?.total_expenses || 0).toLocaleString()}`}
|
||||||
|
subtitle="Outflows this period"
|
||||||
|
icon={TrendingDown}
|
||||||
|
className="border-red-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Additional Stats */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<StatCard
|
||||||
|
title="Net Change"
|
||||||
|
value={`€${(stats?.net_change || 0).toLocaleString()}`}
|
||||||
|
subtitle="Income minus expenses"
|
||||||
|
icon={CreditCard}
|
||||||
|
className={
|
||||||
|
(stats?.net_change || 0) >= 0 ? "border-green-200" : "border-red-200"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Average Transaction"
|
||||||
|
value={`€${Math.abs(stats?.average_transaction || 0).toLocaleString()}`}
|
||||||
|
subtitle="Per transaction"
|
||||||
|
icon={Activity}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Active Accounts"
|
||||||
|
value={stats?.accounts_included || 0}
|
||||||
|
subtitle="With recent activity"
|
||||||
|
icon={Users}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
||||||
|
<BalanceChart data={balances || []} accounts={accounts || []} />
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
||||||
|
<TransactionDistribution accounts={accounts || []} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Monthly Trends */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
||||||
|
<MonthlyTrends days={selectedPeriod.days} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute("/analytics")({
|
export const Route = createFileRoute("/analytics")({
|
||||||
component: () => (
|
component: AnalyticsDashboard,
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Analytics</h3>
|
|
||||||
<p className="text-gray-600">Analytics dashboard coming soon...</p>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import TransactionsList from "../components/TransactionsList";
|
import TransactionsTable from "../components/TransactionsTable";
|
||||||
|
|
||||||
export const Route = createFileRoute("/transactions")({
|
export const Route = createFileRoute("/transactions")({
|
||||||
component: TransactionsList,
|
component: TransactionsTable,
|
||||||
validateSearch: (search) => ({
|
validateSearch: (search) => ({
|
||||||
accountId: search.accountId as string | undefined,
|
accountId: search.accountId as string | undefined,
|
||||||
startDate: search.startDate as string | undefined,
|
startDate: search.startDate as string | undefined,
|
||||||
|
|||||||
@@ -59,6 +59,17 @@ export interface RawTransactionData {
|
|||||||
[key: string]: unknown; // Allow additional fields
|
[key: string]: unknown; // Allow additional fields
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Type for analytics transaction data
|
||||||
|
export interface AnalyticsTransaction {
|
||||||
|
transaction_id: string;
|
||||||
|
date: string;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
status: string;
|
||||||
|
account_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Transaction {
|
export interface Transaction {
|
||||||
transaction_id: string; // NEW: stable bank-provided transaction ID
|
transaction_id: string; // NEW: stable bank-provided transaction ID
|
||||||
internal_transaction_id: string | null; // OLD: unstable GoCardless ID
|
internal_transaction_id: string | null; // OLD: unstable GoCardless ID
|
||||||
@@ -124,6 +135,14 @@ export interface ApiResponse<T> {
|
|||||||
data: T;
|
data: T;
|
||||||
message?: string;
|
message?: string;
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
pagination?: {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
per_page: number;
|
||||||
|
total_pages: number;
|
||||||
|
has_next: boolean;
|
||||||
|
has_prev: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
@@ -180,3 +199,16 @@ export interface HealthData {
|
|||||||
message?: string;
|
message?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Analytics data types
|
||||||
|
export interface TransactionStats {
|
||||||
|
period_days: number;
|
||||||
|
total_transactions: number;
|
||||||
|
booked_transactions: number;
|
||||||
|
pending_transactions: number;
|
||||||
|
total_income: number;
|
||||||
|
total_expenses: number;
|
||||||
|
net_change: number;
|
||||||
|
average_transaction: number;
|
||||||
|
accounts_included: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,57 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
darkMode: ["class"],
|
||||||
|
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {},
|
extend: {
|
||||||
|
borderRadius: {
|
||||||
|
lg: 'var(--radius)',
|
||||||
|
md: 'calc(var(--radius) - 2px)',
|
||||||
|
sm: 'calc(var(--radius) - 4px)'
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
background: 'hsl(var(--background))',
|
||||||
|
foreground: 'hsl(var(--foreground))',
|
||||||
|
card: {
|
||||||
|
DEFAULT: 'hsl(var(--card))',
|
||||||
|
foreground: 'hsl(var(--card-foreground))'
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: 'hsl(var(--popover))',
|
||||||
|
foreground: 'hsl(var(--popover-foreground))'
|
||||||
|
},
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
|
foreground: 'hsl(var(--primary-foreground))'
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
|
foreground: 'hsl(var(--secondary-foreground))'
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
|
foreground: 'hsl(var(--muted-foreground))'
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: 'hsl(var(--accent))',
|
||||||
|
foreground: 'hsl(var(--accent-foreground))'
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
|
foreground: 'hsl(var(--destructive-foreground))'
|
||||||
|
},
|
||||||
|
border: 'hsl(var(--border))',
|
||||||
|
input: 'hsl(var(--input))',
|
||||||
|
ring: 'hsl(var(--ring))',
|
||||||
|
chart: {
|
||||||
|
'1': 'hsl(var(--chart-1))',
|
||||||
|
'2': 'hsl(var(--chart-2))',
|
||||||
|
'3': 'hsl(var(--chart-3))',
|
||||||
|
'4': 'hsl(var(--chart-4))',
|
||||||
|
'5': 'hsl(var(--chart-5))'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
plugins: [require("@tailwindcss/forms")],
|
plugins: [require("@tailwindcss/forms"), require("tailwindcss-animate")],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,12 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Path mapping */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
|
|||||||
@@ -3,5 +3,11 @@
|
|||||||
"references": [
|
"references": [
|
||||||
{ "path": "./tsconfig.app.json" },
|
{ "path": "./tsconfig.app.json" },
|
||||||
{ "path": "./tsconfig.node.json" }
|
{ "path": "./tsconfig.node.json" }
|
||||||
]
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { TanStackRouterVite } from "@tanstack/router-vite-plugin";
|
|||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [TanStackRouterVite(), react()],
|
||||||
TanStackRouterVite(),
|
resolve: {
|
||||||
react(),
|
alias: {
|
||||||
],
|
"@": "/src",
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from typing import Optional, List
|
from typing import List, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
from typing import Optional, List, Union
|
from typing import List, Optional, Union
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from leggend.api.models.common import APIResponse
|
from leggen.api.models.accounts import (
|
||||||
from leggend.api.models.accounts import (
|
|
||||||
AccountDetails,
|
|
||||||
AccountBalance,
|
AccountBalance,
|
||||||
|
AccountDetails,
|
||||||
|
AccountUpdate,
|
||||||
Transaction,
|
Transaction,
|
||||||
TransactionSummary,
|
TransactionSummary,
|
||||||
AccountUpdate,
|
|
||||||
)
|
)
|
||||||
from leggend.services.database_service import DatabaseService
|
from leggen.api.models.common import APIResponse
|
||||||
|
from leggen.services.database_service import DatabaseService
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
database_service = DatabaseService()
|
database_service = DatabaseService()
|
||||||
@@ -215,6 +216,35 @@ async def get_all_balances() -> APIResponse:
|
|||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/balances/history", response_model=APIResponse)
|
||||||
|
async def get_historical_balances(
|
||||||
|
days: Optional[int] = Query(
|
||||||
|
default=365, le=1095, ge=1, description="Number of days of history to retrieve"
|
||||||
|
),
|
||||||
|
account_id: Optional[str] = Query(
|
||||||
|
default=None, description="Filter by specific account ID"
|
||||||
|
),
|
||||||
|
) -> APIResponse:
|
||||||
|
"""Get historical balance progression calculated from transaction history"""
|
||||||
|
try:
|
||||||
|
# Get historical balances from database
|
||||||
|
historical_balances = await database_service.get_historical_balances_from_db(
|
||||||
|
account_id=account_id, days=days or 365
|
||||||
|
)
|
||||||
|
|
||||||
|
return APIResponse(
|
||||||
|
success=True,
|
||||||
|
data=historical_balances,
|
||||||
|
message=f"Retrieved {len(historical_balances)} historical balance points over {days} days",
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get historical balances: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to get historical balances: {str(e)}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
@router.get("/accounts/{account_id}/transactions", response_model=APIResponse)
|
@router.get("/accounts/{account_id}/transactions", response_model=APIResponse)
|
||||||
async def get_account_transactions(
|
async def get_account_transactions(
|
||||||
account_id: str,
|
account_id: str,
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from leggend.api.models.common import APIResponse
|
from leggen.api.models.banks import (
|
||||||
from leggend.api.models.banks import (
|
|
||||||
BankInstitution,
|
|
||||||
BankConnectionRequest,
|
BankConnectionRequest,
|
||||||
BankRequisition,
|
|
||||||
BankConnectionStatus,
|
BankConnectionStatus,
|
||||||
|
BankInstitution,
|
||||||
|
BankRequisition,
|
||||||
)
|
)
|
||||||
from leggend.services.gocardless_service import GoCardlessService
|
from leggen.api.models.common import APIResponse
|
||||||
from leggend.utils.gocardless import REQUISITION_STATUS
|
from leggen.services.gocardless_service import GoCardlessService
|
||||||
|
from leggen.utils.gocardless import REQUISITION_STATUS
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
gocardless_service = GoCardlessService()
|
gocardless_service = GoCardlessService()
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
from typing import Dict, Any
|
from typing import Any, Dict
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from leggend.api.models.common import APIResponse
|
from leggen.api.models.common import APIResponse
|
||||||
from leggend.api.models.notifications import (
|
from leggen.api.models.notifications import (
|
||||||
|
DiscordConfig,
|
||||||
|
NotificationFilters,
|
||||||
NotificationSettings,
|
NotificationSettings,
|
||||||
NotificationTest,
|
NotificationTest,
|
||||||
DiscordConfig,
|
|
||||||
TelegramConfig,
|
TelegramConfig,
|
||||||
NotificationFilters,
|
|
||||||
)
|
)
|
||||||
from leggend.services.notification_service import NotificationService
|
from leggen.services.notification_service import NotificationService
|
||||||
from leggend.config import config
|
from leggen.utils.config import config
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
notification_service = NotificationService()
|
notification_service = NotificationService()
|
||||||
@@ -36,14 +37,11 @@ async def get_notification_settings() -> APIResponse:
|
|||||||
if discord_config.get("webhook")
|
if discord_config.get("webhook")
|
||||||
else None,
|
else None,
|
||||||
telegram=TelegramConfig(
|
telegram=TelegramConfig(
|
||||||
token="***"
|
token="***" if telegram_config.get("api-key") else "",
|
||||||
if (telegram_config.get("token") or telegram_config.get("api-key"))
|
chat_id=telegram_config.get("chat-id", 0),
|
||||||
else "",
|
|
||||||
chat_id=telegram_config.get("chat_id")
|
|
||||||
or telegram_config.get("chat-id", 0),
|
|
||||||
enabled=telegram_config.get("enabled", True),
|
enabled=telegram_config.get("enabled", True),
|
||||||
)
|
)
|
||||||
if (telegram_config.get("token") or telegram_config.get("api-key"))
|
if telegram_config.get("api-key")
|
||||||
else None,
|
else None,
|
||||||
filters=NotificationFilters(
|
filters=NotificationFilters(
|
||||||
case_insensitive=filters_config.get("case-insensitive", []),
|
case_insensitive=filters_config.get("case-insensitive", []),
|
||||||
@@ -79,8 +77,8 @@ async def update_notification_settings(settings: NotificationSettings) -> APIRes
|
|||||||
|
|
||||||
if settings.telegram:
|
if settings.telegram:
|
||||||
notifications_config["telegram"] = {
|
notifications_config["telegram"] = {
|
||||||
"token": settings.telegram.token,
|
"api-key": settings.telegram.token,
|
||||||
"chat_id": settings.telegram.chat_id,
|
"chat-id": settings.telegram.chat_id,
|
||||||
"enabled": settings.telegram.enabled,
|
"enabled": settings.telegram.enabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,24 +153,12 @@ async def get_notification_services() -> APIResponse:
|
|||||||
"telegram": {
|
"telegram": {
|
||||||
"name": "Telegram",
|
"name": "Telegram",
|
||||||
"enabled": bool(
|
"enabled": bool(
|
||||||
(
|
notifications_config.get("telegram", {}).get("api-key")
|
||||||
notifications_config.get("telegram", {}).get("token")
|
and notifications_config.get("telegram", {}).get("chat-id")
|
||||||
or notifications_config.get("telegram", {}).get("api-key")
|
|
||||||
)
|
|
||||||
and (
|
|
||||||
notifications_config.get("telegram", {}).get("chat_id")
|
|
||||||
or notifications_config.get("telegram", {}).get("chat-id")
|
|
||||||
)
|
|
||||||
),
|
),
|
||||||
"configured": bool(
|
"configured": bool(
|
||||||
(
|
notifications_config.get("telegram", {}).get("api-key")
|
||||||
notifications_config.get("telegram", {}).get("token")
|
and notifications_config.get("telegram", {}).get("chat-id")
|
||||||
or notifications_config.get("telegram", {}).get("api-key")
|
|
||||||
)
|
|
||||||
and (
|
|
||||||
notifications_config.get("telegram", {}).get("chat_id")
|
|
||||||
or notifications_config.get("telegram", {}).get("chat-id")
|
|
||||||
)
|
|
||||||
),
|
),
|
||||||
"active": notifications_config.get("telegram", {}).get("enabled", True),
|
"active": notifications_config.get("telegram", {}).get("enabled", True),
|
||||||
},
|
},
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import APIRouter, HTTPException, BackgroundTasks
|
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from leggend.api.models.common import APIResponse
|
from leggen.api.models.common import APIResponse
|
||||||
from leggend.api.models.sync import SyncRequest, SchedulerConfig
|
from leggen.api.models.sync import SchedulerConfig, SyncRequest
|
||||||
from leggend.services.sync_service import SyncService
|
from leggen.background.scheduler import scheduler
|
||||||
from leggend.background.scheduler import scheduler
|
from leggen.services.sync_service import SyncService
|
||||||
from leggend.config import config
|
from leggen.utils.config import config
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
sync_service = SyncService()
|
sync_service = SyncService()
|
||||||
@@ -1,20 +1,21 @@
|
|||||||
from typing import Optional, List, Union
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from typing import List, Optional, Union
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from leggend.api.models.common import APIResponse
|
from leggen.api.models.accounts import Transaction, TransactionSummary
|
||||||
from leggend.api.models.accounts import Transaction, TransactionSummary
|
from leggen.api.models.common import APIResponse, PaginatedResponse
|
||||||
from leggend.services.database_service import DatabaseService
|
from leggen.services.database_service import DatabaseService
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
database_service = DatabaseService()
|
database_service = DatabaseService()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/transactions", response_model=APIResponse)
|
@router.get("/transactions", response_model=PaginatedResponse)
|
||||||
async def get_all_transactions(
|
async def get_all_transactions(
|
||||||
limit: Optional[int] = Query(default=100, le=500),
|
page: int = Query(default=1, ge=1, description="Page number (1-based)"),
|
||||||
offset: Optional[int] = Query(default=0, ge=0),
|
per_page: int = Query(default=50, le=500, description="Items per page"),
|
||||||
summary_only: bool = Query(
|
summary_only: bool = Query(
|
||||||
default=True, description="Return transaction summaries only"
|
default=True, description="Return transaction summaries only"
|
||||||
),
|
),
|
||||||
@@ -34,9 +35,13 @@ async def get_all_transactions(
|
|||||||
default=None, description="Search in transaction descriptions"
|
default=None, description="Search in transaction descriptions"
|
||||||
),
|
),
|
||||||
account_id: Optional[str] = Query(default=None, description="Filter by account ID"),
|
account_id: Optional[str] = Query(default=None, description="Filter by account ID"),
|
||||||
) -> APIResponse:
|
) -> PaginatedResponse:
|
||||||
"""Get all transactions from database with filtering options"""
|
"""Get all transactions from database with filtering options"""
|
||||||
try:
|
try:
|
||||||
|
# Calculate offset from page and per_page
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
limit = per_page
|
||||||
|
|
||||||
# Get transactions from database instead of GoCardless API
|
# Get transactions from database instead of GoCardless API
|
||||||
db_transactions = await database_service.get_transactions_from_db(
|
db_transactions = await database_service.get_transactions_from_db(
|
||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
@@ -59,16 +64,6 @@ async def get_all_transactions(
|
|||||||
search=search,
|
search=search,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get total count for pagination info
|
|
||||||
total_transactions = await database_service.get_transaction_count_from_db(
|
|
||||||
account_id=account_id,
|
|
||||||
date_from=date_from,
|
|
||||||
date_to=date_to,
|
|
||||||
min_amount=min_amount,
|
|
||||||
max_amount=max_amount,
|
|
||||||
search=search,
|
|
||||||
)
|
|
||||||
|
|
||||||
data: Union[List[TransactionSummary], List[Transaction]]
|
data: Union[List[TransactionSummary], List[Transaction]]
|
||||||
|
|
||||||
if summary_only:
|
if summary_only:
|
||||||
@@ -105,11 +100,19 @@ async def get_all_transactions(
|
|||||||
for txn in db_transactions
|
for txn in db_transactions
|
||||||
]
|
]
|
||||||
|
|
||||||
actual_offset = offset or 0
|
total_pages = (total_transactions + per_page - 1) // per_page
|
||||||
return APIResponse(
|
|
||||||
|
return PaginatedResponse(
|
||||||
success=True,
|
success=True,
|
||||||
data=data,
|
data=data,
|
||||||
message=f"Retrieved {len(data)} transactions (showing {actual_offset + 1}-{actual_offset + len(data)} of {total_transactions})",
|
pagination={
|
||||||
|
"total": total_transactions,
|
||||||
|
"page": page,
|
||||||
|
"per_page": per_page,
|
||||||
|
"total_pages": total_pages,
|
||||||
|
"has_next": page < total_pages,
|
||||||
|
"has_prev": page > 1,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -200,3 +203,88 @@ async def get_transaction_stats(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Failed to get transaction stats: {str(e)}"
|
status_code=500, detail=f"Failed to get transaction stats: {str(e)}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/transactions/analytics", response_model=APIResponse)
|
||||||
|
async def get_transactions_for_analytics(
|
||||||
|
days: int = Query(default=365, description="Number of days to include"),
|
||||||
|
account_id: Optional[str] = Query(default=None, description="Filter by account ID"),
|
||||||
|
) -> APIResponse:
|
||||||
|
"""Get all transactions for analytics (no pagination) for the last N days"""
|
||||||
|
try:
|
||||||
|
# Date range for analytics
|
||||||
|
end_date = datetime.now()
|
||||||
|
start_date = end_date - timedelta(days=days)
|
||||||
|
|
||||||
|
# Format dates for database query
|
||||||
|
date_from = start_date.isoformat()
|
||||||
|
date_to = end_date.isoformat()
|
||||||
|
|
||||||
|
# Get ALL transactions from database (no limit for analytics)
|
||||||
|
transactions = await database_service.get_transactions_from_db(
|
||||||
|
account_id=account_id,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
limit=None, # No limit - get all transactions
|
||||||
|
)
|
||||||
|
|
||||||
|
# Transform for frontend (summary format)
|
||||||
|
transaction_summaries = [
|
||||||
|
{
|
||||||
|
"transaction_id": txn["transactionId"],
|
||||||
|
"date": txn["transactionDate"],
|
||||||
|
"description": txn["description"],
|
||||||
|
"amount": txn["transactionValue"],
|
||||||
|
"currency": txn["transactionCurrency"],
|
||||||
|
"status": txn["transactionStatus"],
|
||||||
|
"account_id": txn["accountId"],
|
||||||
|
}
|
||||||
|
for txn in transactions
|
||||||
|
]
|
||||||
|
|
||||||
|
return APIResponse(
|
||||||
|
success=True,
|
||||||
|
data=transaction_summaries,
|
||||||
|
message=f"Retrieved {len(transaction_summaries)} transactions for analytics",
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get transactions for analytics: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to get analytics transactions: {str(e)}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/transactions/monthly-stats", response_model=APIResponse)
|
||||||
|
async def get_monthly_transaction_stats(
|
||||||
|
days: int = Query(default=365, description="Number of days to include"),
|
||||||
|
account_id: Optional[str] = Query(default=None, description="Filter by account ID"),
|
||||||
|
) -> APIResponse:
|
||||||
|
"""Get monthly transaction statistics aggregated by the database"""
|
||||||
|
try:
|
||||||
|
# Date range for monthly stats
|
||||||
|
end_date = datetime.now()
|
||||||
|
start_date = end_date - timedelta(days=days)
|
||||||
|
|
||||||
|
# Format dates for database query
|
||||||
|
date_from = start_date.isoformat()
|
||||||
|
date_to = end_date.isoformat()
|
||||||
|
|
||||||
|
# Get monthly aggregated stats from database
|
||||||
|
monthly_stats = await database_service.get_monthly_transaction_stats_from_db(
|
||||||
|
account_id=account_id,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
)
|
||||||
|
|
||||||
|
return APIResponse(
|
||||||
|
success=True,
|
||||||
|
data=monthly_stats,
|
||||||
|
message=f"Retrieved monthly stats for last {days} days",
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get monthly transaction stats: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to get monthly stats: {str(e)}"
|
||||||
|
) from e
|
||||||
@@ -1,20 +1,21 @@
|
|||||||
import os
|
import os
|
||||||
import requests
|
from typing import Any, Dict, List, Optional, Union
|
||||||
from typing import Dict, Any, Optional, List, Union
|
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
from leggen.utils.text import error
|
from leggen.utils.text import error
|
||||||
|
|
||||||
|
|
||||||
class LeggendAPIClient:
|
class LeggenAPIClient:
|
||||||
"""Client for communicating with the leggend FastAPI service"""
|
"""Client for communicating with the leggen FastAPI service"""
|
||||||
|
|
||||||
base_url: str
|
base_url: str
|
||||||
|
|
||||||
def __init__(self, base_url: Optional[str] = None):
|
def __init__(self, base_url: Optional[str] = None):
|
||||||
self.base_url = (
|
self.base_url = (
|
||||||
base_url
|
base_url
|
||||||
or os.environ.get("LEGGEND_API_URL", "http://localhost:8000")
|
or os.environ.get("LEGGEN_API_URL", "http://localhost:8000")
|
||||||
or "http://localhost:8000"
|
or "http://localhost:8000"
|
||||||
)
|
)
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
@@ -31,7 +32,7 @@ class LeggendAPIClient:
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
except requests.exceptions.ConnectionError:
|
except requests.exceptions.ConnectionError:
|
||||||
error("Could not connect to leggend service. Is it running?")
|
error("Could not connect to leggen server. Is it running?")
|
||||||
error(f"Trying to connect to: {self.base_url}")
|
error(f"Trying to connect to: {self.base_url}")
|
||||||
raise
|
raise
|
||||||
except requests.exceptions.HTTPError as e:
|
except requests.exceptions.HTTPError as e:
|
||||||
@@ -48,7 +49,7 @@ class LeggendAPIClient:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
def health_check(self) -> bool:
|
def health_check(self) -> bool:
|
||||||
"""Check if the leggend service is healthy"""
|
"""Check if the leggen server is healthy"""
|
||||||
try:
|
try:
|
||||||
response = self._make_request("GET", "/health")
|
response = self._make_request("GET", "/health")
|
||||||
return response.get("status") == "healthy"
|
return response.get("status") == "healthy"
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|||||||
from apscheduler.triggers.cron import CronTrigger
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from leggend.config import config
|
from leggen.services.notification_service import NotificationService
|
||||||
from leggend.services.sync_service import SyncService
|
from leggen.services.sync_service import SyncService
|
||||||
from leggend.services.notification_service import NotificationService
|
from leggen.utils.config import config
|
||||||
|
|
||||||
|
|
||||||
class BackgroundScheduler:
|
class BackgroundScheduler:
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import click
|
import click
|
||||||
|
|
||||||
|
from leggen.api_client import LeggenAPIClient
|
||||||
from leggen.main import cli
|
from leggen.main import cli
|
||||||
from leggen.api_client import LeggendAPIClient
|
|
||||||
from leggen.utils.text import datefmt, print_table
|
from leggen.utils.text import datefmt, print_table
|
||||||
|
|
||||||
|
|
||||||
@@ -11,12 +11,12 @@ def balances(ctx: click.Context):
|
|||||||
"""
|
"""
|
||||||
List balances of all connected accounts
|
List balances of all connected accounts
|
||||||
"""
|
"""
|
||||||
api_client = LeggendAPIClient(ctx.obj.get("api_url"))
|
api_client = LeggenAPIClient(ctx.obj.get("api_url"))
|
||||||
|
|
||||||
# Check if leggend service is available
|
# Check if leggen server is available
|
||||||
if not api_client.health_check():
|
if not api_client.health_check():
|
||||||
click.echo(
|
click.echo(
|
||||||
"Error: Cannot connect to leggend service. Please ensure it's running."
|
"Error: Cannot connect to leggen server. Please ensure it's running."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import click
|
import click
|
||||||
|
|
||||||
|
from leggen.api_client import LeggenAPIClient
|
||||||
from leggen.main import cli
|
from leggen.main import cli
|
||||||
from leggen.api_client import LeggendAPIClient
|
|
||||||
from leggen.utils.disk import save_file
|
from leggen.utils.disk import save_file
|
||||||
from leggen.utils.text import info, print_table, warning, success
|
from leggen.utils.text import info, print_table, success, warning
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@@ -12,12 +12,12 @@ def add(ctx):
|
|||||||
"""
|
"""
|
||||||
Connect to a bank
|
Connect to a bank
|
||||||
"""
|
"""
|
||||||
api_client = LeggendAPIClient(ctx.obj.get("api_url"))
|
api_client = LeggenAPIClient(ctx.obj.get("api_url"))
|
||||||
|
|
||||||
# Check if leggend service is available
|
# Check if leggen server is available
|
||||||
if not api_client.health_check():
|
if not api_client.health_check():
|
||||||
click.echo(
|
click.echo(
|
||||||
"Error: Cannot connect to leggend service. Please ensure it's running."
|
"Error: Cannot connect to leggen server. Please ensure it's running."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
68
leggen/commands/generate_sample_db.py
Normal file
68
leggen/commands/generate_sample_db.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""Generate sample database command."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option(
|
||||||
|
"--database",
|
||||||
|
type=click.Path(path_type=Path),
|
||||||
|
help="Path to database file (default: uses LEGGEN_DATABASE_PATH or ~/.config/leggen/leggen-dev.db)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--accounts",
|
||||||
|
type=int,
|
||||||
|
default=3,
|
||||||
|
help="Number of sample accounts to generate (default: 3)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--transactions",
|
||||||
|
type=int,
|
||||||
|
default=50,
|
||||||
|
help="Number of transactions per account (default: 50)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--force",
|
||||||
|
is_flag=True,
|
||||||
|
help="Overwrite existing database without confirmation",
|
||||||
|
)
|
||||||
|
@click.pass_context
|
||||||
|
def generate_sample_db(
|
||||||
|
ctx: click.Context, database: Path, accounts: int, transactions: int, force: bool
|
||||||
|
):
|
||||||
|
"""Generate a sample database with realistic financial data for testing."""
|
||||||
|
|
||||||
|
# Import here to avoid circular imports
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path as PathlibPath
|
||||||
|
|
||||||
|
# Get the script path
|
||||||
|
script_path = (
|
||||||
|
PathlibPath(__file__).parent.parent.parent / "scripts" / "generate_sample_db.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build command arguments
|
||||||
|
cmd = [sys.executable, str(script_path)]
|
||||||
|
|
||||||
|
if database:
|
||||||
|
cmd.extend(["--database", str(database)])
|
||||||
|
|
||||||
|
cmd.extend(["--accounts", str(accounts)])
|
||||||
|
cmd.extend(["--transactions", str(transactions)])
|
||||||
|
|
||||||
|
if force:
|
||||||
|
cmd.append("--force")
|
||||||
|
|
||||||
|
# Execute the script
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
click.echo(f"Error generating sample database: {e}")
|
||||||
|
ctx.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# Export the command
|
||||||
|
generate_sample_db = generate_sample_db
|
||||||
@@ -1,20 +1,22 @@
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from importlib import metadata
|
from importlib import metadata
|
||||||
|
|
||||||
|
import click
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from leggend.api.routes import banks, accounts, sync, notifications, transactions
|
from leggen.api.routes import accounts, banks, notifications, sync, transactions
|
||||||
from leggend.background.scheduler import scheduler
|
from leggen.background.scheduler import scheduler
|
||||||
from leggend.config import config
|
from leggen.utils.config import config
|
||||||
|
from leggen.utils.paths import path_manager
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# Startup
|
# Startup
|
||||||
logger.info("Starting leggend service...")
|
logger.info("Starting leggen server...")
|
||||||
|
|
||||||
# Load configuration
|
# Load configuration
|
||||||
try:
|
try:
|
||||||
@@ -26,7 +28,7 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
# Run database migrations
|
# Run database migrations
|
||||||
try:
|
try:
|
||||||
from leggend.services.database_service import DatabaseService
|
from leggen.services.database_service import DatabaseService
|
||||||
|
|
||||||
db_service = DatabaseService()
|
db_service = DatabaseService()
|
||||||
await db_service.run_migrations_if_needed()
|
await db_service.run_migrations_if_needed()
|
||||||
@@ -42,7 +44,7 @@ async def lifespan(app: FastAPI):
|
|||||||
yield
|
yield
|
||||||
|
|
||||||
# Shutdown
|
# Shutdown
|
||||||
logger.info("Shutting down leggend service...")
|
logger.info("Shutting down leggen server...")
|
||||||
scheduler.shutdown()
|
scheduler.shutdown()
|
||||||
|
|
||||||
|
|
||||||
@@ -54,7 +56,7 @@ def create_app() -> FastAPI:
|
|||||||
version = "unknown"
|
version = "unknown"
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Leggend API",
|
title="Leggen API",
|
||||||
description="Open Banking API for Leggen",
|
description="Open Banking API for Leggen",
|
||||||
version=version,
|
version=version,
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
@@ -87,13 +89,13 @@ def create_app() -> FastAPI:
|
|||||||
version = metadata.version("leggen")
|
version = metadata.version("leggen")
|
||||||
except metadata.PackageNotFoundError:
|
except metadata.PackageNotFoundError:
|
||||||
version = "unknown"
|
version = "unknown"
|
||||||
return {"message": "Leggend API is running", "version": version}
|
return {"message": "Leggen API is running", "version": version}
|
||||||
|
|
||||||
@app.get("/api/v1/health")
|
@app.get("/api/v1/health")
|
||||||
async def health():
|
async def health():
|
||||||
"""Health check endpoint for API connectivity"""
|
"""Health check endpoint for API connectivity"""
|
||||||
try:
|
try:
|
||||||
from leggend.api.models.common import APIResponse
|
from leggen.api.models.common import APIResponse
|
||||||
|
|
||||||
config_loaded = config._config is not None
|
config_loaded = config._config is not None
|
||||||
|
|
||||||
@@ -108,7 +110,7 @@ def create_app() -> FastAPI:
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Health check failed: {e}")
|
logger.error(f"Health check failed: {e}")
|
||||||
from leggend.api.models.common import APIResponse
|
from leggen.api.models.common import APIResponse
|
||||||
|
|
||||||
return APIResponse(
|
return APIResponse(
|
||||||
success=False,
|
success=False,
|
||||||
@@ -119,43 +121,58 @@ def create_app() -> FastAPI:
|
|||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
def main():
|
@click.command()
|
||||||
import argparse
|
@click.option(
|
||||||
|
"--reload",
|
||||||
|
is_flag=True,
|
||||||
|
help="Enable auto-reload for development",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--host",
|
||||||
|
default="0.0.0.0",
|
||||||
|
help="Host to bind to (default: 0.0.0.0)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--port",
|
||||||
|
type=int,
|
||||||
|
default=8000,
|
||||||
|
help="Port to bind to (default: 8000)",
|
||||||
|
)
|
||||||
|
@click.pass_context
|
||||||
|
def server(ctx: click.Context, reload: bool, host: str, port: int):
|
||||||
|
"""Start the Leggen API server"""
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="Start the Leggend API service")
|
# Get config_dir and database from main CLI context
|
||||||
parser.add_argument(
|
config_dir = None
|
||||||
"--reload", action="store_true", help="Enable auto-reload for development"
|
database = None
|
||||||
)
|
if ctx.parent:
|
||||||
parser.add_argument(
|
config_dir = ctx.parent.params.get("config_dir")
|
||||||
"--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)"
|
database = ctx.parent.params.get("database")
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--port", type=int, default=8000, help="Port to bind to (default: 8000)"
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if args.reload:
|
# Set up path manager with user-provided paths
|
||||||
|
if config_dir:
|
||||||
|
path_manager.set_config_dir(config_dir)
|
||||||
|
if database:
|
||||||
|
path_manager.set_database_path(database)
|
||||||
|
|
||||||
|
if reload:
|
||||||
# Use string import for reload to work properly
|
# Use string import for reload to work properly
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
"leggend.main:create_app",
|
"leggen.commands.server:create_app",
|
||||||
factory=True,
|
factory=True,
|
||||||
host=args.host,
|
host=host,
|
||||||
port=args.port,
|
port=port,
|
||||||
log_level="info",
|
log_level="info",
|
||||||
access_log=True,
|
access_log=True,
|
||||||
reload=True,
|
reload=True,
|
||||||
reload_dirs=["leggend", "leggen"], # Watch both directories
|
reload_dirs=["leggen"], # Watch leggen directory
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
app = create_app()
|
app = create_app()
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
app,
|
app,
|
||||||
host=args.host,
|
host=host,
|
||||||
port=args.port,
|
port=port,
|
||||||
log_level="info",
|
log_level="info",
|
||||||
access_log=True,
|
access_log=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import click
|
import click
|
||||||
|
|
||||||
|
from leggen.api_client import LeggenAPIClient
|
||||||
from leggen.main import cli
|
from leggen.main import cli
|
||||||
from leggen.api_client import LeggendAPIClient
|
|
||||||
from leggen.utils.text import datefmt, echo, info, print_table
|
from leggen.utils.text import datefmt, echo, info, print_table
|
||||||
|
|
||||||
|
|
||||||
@@ -11,12 +11,12 @@ def status(ctx: click.Context):
|
|||||||
"""
|
"""
|
||||||
List all connected banks and their status
|
List all connected banks and their status
|
||||||
"""
|
"""
|
||||||
api_client = LeggendAPIClient(ctx.obj.get("api_url"))
|
api_client = LeggenAPIClient(ctx.obj.get("api_url"))
|
||||||
|
|
||||||
# Check if leggend service is available
|
# Check if leggen server is available
|
||||||
if not api_client.health_check():
|
if not api_client.health_check():
|
||||||
click.echo(
|
click.echo(
|
||||||
"Error: Cannot connect to leggend service. Please ensure it's running."
|
"Error: Cannot connect to leggen server. Please ensure it's running."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import click
|
import click
|
||||||
|
|
||||||
|
from leggen.api_client import LeggenAPIClient
|
||||||
from leggen.main import cli
|
from leggen.main import cli
|
||||||
from leggen.api_client import LeggendAPIClient
|
|
||||||
from leggen.utils.text import error, info, success
|
from leggen.utils.text import error, info, success
|
||||||
|
|
||||||
|
|
||||||
@@ -13,11 +13,11 @@ def sync(ctx: click.Context, wait: bool, force: bool):
|
|||||||
"""
|
"""
|
||||||
Sync all transactions with database
|
Sync all transactions with database
|
||||||
"""
|
"""
|
||||||
api_client = LeggendAPIClient(ctx.obj.get("api_url"))
|
api_client = LeggenAPIClient(ctx.obj.get("api_url"))
|
||||||
|
|
||||||
# Check if leggend service is available
|
# Check if leggen server is available
|
||||||
if not api_client.health_check():
|
if not api_client.health_check():
|
||||||
error("Cannot connect to leggend service. Please ensure it's running.")
|
error("Cannot connect to leggen server. Please ensure it's running.")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import click
|
import click
|
||||||
|
|
||||||
|
from leggen.api_client import LeggenAPIClient
|
||||||
from leggen.main import cli
|
from leggen.main import cli
|
||||||
from leggen.api_client import LeggendAPIClient
|
|
||||||
from leggen.utils.text import datefmt, info, print_table
|
from leggen.utils.text import datefmt, info, print_table
|
||||||
|
|
||||||
|
|
||||||
@@ -20,12 +20,12 @@ def transactions(ctx: click.Context, account: str, limit: int, full: bool):
|
|||||||
|
|
||||||
If the --account option is used, it will only list transactions for that account.
|
If the --account option is used, it will only list transactions for that account.
|
||||||
"""
|
"""
|
||||||
api_client = LeggendAPIClient(ctx.obj.get("api_url"))
|
api_client = LeggenAPIClient(ctx.obj.get("api_url"))
|
||||||
|
|
||||||
# Check if leggend service is available
|
# Check if leggen server is available
|
||||||
if not api_client.health_check():
|
if not api_client.health_check():
|
||||||
click.echo(
|
click.echo(
|
||||||
"Error: Cannot connect to leggend service. Please ensure it's running."
|
"Error: Cannot connect to leggen server. Please ensure it's running."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -1,538 +0,0 @@
|
|||||||
import json
|
|
||||||
import sqlite3
|
|
||||||
from sqlite3 import IntegrityError
|
|
||||||
|
|
||||||
import click
|
|
||||||
|
|
||||||
from leggen.utils.text import success, warning
|
|
||||||
|
|
||||||
|
|
||||||
def persist_balances(ctx: click.Context, balance: dict):
|
|
||||||
# Connect to SQLite database
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
|
||||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# Create the accounts table if it doesn't exist
|
|
||||||
cursor.execute(
|
|
||||||
"""CREATE TABLE IF NOT EXISTS accounts (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
institution_id TEXT,
|
|
||||||
status TEXT,
|
|
||||||
iban TEXT,
|
|
||||||
name TEXT,
|
|
||||||
currency TEXT,
|
|
||||||
created DATETIME,
|
|
||||||
last_accessed DATETIME,
|
|
||||||
last_updated DATETIME
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create indexes for accounts table
|
|
||||||
cursor.execute(
|
|
||||||
"""CREATE INDEX IF NOT EXISTS idx_accounts_institution_id
|
|
||||||
ON accounts(institution_id)"""
|
|
||||||
)
|
|
||||||
cursor.execute(
|
|
||||||
"""CREATE INDEX IF NOT EXISTS idx_accounts_status
|
|
||||||
ON accounts(status)"""
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create the balances table if it doesn't exist
|
|
||||||
cursor.execute(
|
|
||||||
"""CREATE TABLE IF NOT EXISTS balances (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
account_id TEXT,
|
|
||||||
bank TEXT,
|
|
||||||
status TEXT,
|
|
||||||
iban TEXT,
|
|
||||||
amount REAL,
|
|
||||||
currency TEXT,
|
|
||||||
type TEXT,
|
|
||||||
timestamp DATETIME
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create indexes for better performance
|
|
||||||
cursor.execute(
|
|
||||||
"""CREATE INDEX IF NOT EXISTS idx_balances_account_id
|
|
||||||
ON balances(account_id)"""
|
|
||||||
)
|
|
||||||
cursor.execute(
|
|
||||||
"""CREATE INDEX IF NOT EXISTS idx_balances_timestamp
|
|
||||||
ON balances(timestamp)"""
|
|
||||||
)
|
|
||||||
cursor.execute(
|
|
||||||
"""CREATE INDEX IF NOT EXISTS idx_balances_account_type_timestamp
|
|
||||||
ON balances(account_id, type, timestamp)"""
|
|
||||||
)
|
|
||||||
|
|
||||||
# Insert balance into SQLite database
|
|
||||||
try:
|
|
||||||
cursor.execute(
|
|
||||||
"""INSERT INTO balances (
|
|
||||||
account_id,
|
|
||||||
bank,
|
|
||||||
status,
|
|
||||||
iban,
|
|
||||||
amount,
|
|
||||||
currency,
|
|
||||||
type,
|
|
||||||
timestamp
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
||||||
(
|
|
||||||
balance["account_id"],
|
|
||||||
balance["bank"],
|
|
||||||
balance["status"],
|
|
||||||
balance["iban"],
|
|
||||||
balance["amount"],
|
|
||||||
balance["currency"],
|
|
||||||
balance["type"],
|
|
||||||
balance["timestamp"],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
except IntegrityError:
|
|
||||||
warning(f"[{balance['account_id']}] Skipped duplicate balance")
|
|
||||||
|
|
||||||
# Commit changes and close the connection
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
success(f"[{balance['account_id']}] Inserted balance of type {balance['type']}")
|
|
||||||
|
|
||||||
return balance
|
|
||||||
|
|
||||||
|
|
||||||
def persist_transactions(ctx: click.Context, account: str, transactions: list) -> list:
|
|
||||||
# Connect to SQLite database
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
|
||||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# Create the transactions table if it doesn't exist
|
|
||||||
cursor.execute(
|
|
||||||
"""CREATE TABLE IF NOT EXISTS transactions (
|
|
||||||
internalTransactionId TEXT PRIMARY KEY,
|
|
||||||
institutionId TEXT,
|
|
||||||
iban TEXT,
|
|
||||||
transactionDate DATETIME,
|
|
||||||
description TEXT,
|
|
||||||
transactionValue REAL,
|
|
||||||
transactionCurrency TEXT,
|
|
||||||
transactionStatus TEXT,
|
|
||||||
accountId TEXT,
|
|
||||||
rawTransaction JSON
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create indexes for better performance
|
|
||||||
cursor.execute(
|
|
||||||
"""CREATE INDEX IF NOT EXISTS idx_transactions_account_id
|
|
||||||
ON transactions(accountId)"""
|
|
||||||
)
|
|
||||||
cursor.execute(
|
|
||||||
"""CREATE INDEX IF NOT EXISTS idx_transactions_date
|
|
||||||
ON transactions(transactionDate)"""
|
|
||||||
)
|
|
||||||
cursor.execute(
|
|
||||||
"""CREATE INDEX IF NOT EXISTS idx_transactions_account_date
|
|
||||||
ON transactions(accountId, transactionDate)"""
|
|
||||||
)
|
|
||||||
cursor.execute(
|
|
||||||
"""CREATE INDEX IF NOT EXISTS idx_transactions_amount
|
|
||||||
ON transactions(transactionValue)"""
|
|
||||||
)
|
|
||||||
|
|
||||||
# Insert transactions into SQLite database
|
|
||||||
duplicates_count = 0
|
|
||||||
|
|
||||||
# Prepare an SQL statement for inserting data
|
|
||||||
insert_sql = """INSERT INTO transactions (
|
|
||||||
internalTransactionId,
|
|
||||||
institutionId,
|
|
||||||
iban,
|
|
||||||
transactionDate,
|
|
||||||
description,
|
|
||||||
transactionValue,
|
|
||||||
transactionCurrency,
|
|
||||||
transactionStatus,
|
|
||||||
accountId,
|
|
||||||
rawTransaction
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"""
|
|
||||||
|
|
||||||
new_transactions = []
|
|
||||||
|
|
||||||
for transaction in transactions:
|
|
||||||
try:
|
|
||||||
cursor.execute(
|
|
||||||
insert_sql,
|
|
||||||
(
|
|
||||||
transaction["internalTransactionId"],
|
|
||||||
transaction["institutionId"],
|
|
||||||
transaction["iban"],
|
|
||||||
transaction["transactionDate"],
|
|
||||||
transaction["description"],
|
|
||||||
transaction["transactionValue"],
|
|
||||||
transaction["transactionCurrency"],
|
|
||||||
transaction["transactionStatus"],
|
|
||||||
transaction["accountId"],
|
|
||||||
json.dumps(transaction["rawTransaction"]),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
new_transactions.append(transaction)
|
|
||||||
except IntegrityError:
|
|
||||||
# A transaction with the same ID already exists, indicating a duplicate
|
|
||||||
duplicates_count += 1
|
|
||||||
|
|
||||||
# Commit changes and close the connection
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
success(f"[{account}] Inserted {len(new_transactions)} new transactions")
|
|
||||||
if duplicates_count:
|
|
||||||
warning(f"[{account}] Skipped {duplicates_count} duplicate transactions")
|
|
||||||
|
|
||||||
return new_transactions
|
|
||||||
|
|
||||||
|
|
||||||
def get_transactions(
|
|
||||||
account_id=None,
|
|
||||||
limit=100,
|
|
||||||
offset=0,
|
|
||||||
date_from=None,
|
|
||||||
date_to=None,
|
|
||||||
min_amount=None,
|
|
||||||
max_amount=None,
|
|
||||||
search=None,
|
|
||||||
):
|
|
||||||
"""Get transactions from SQLite database with optional filtering"""
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
|
||||||
if not db_path.exists():
|
|
||||||
return []
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
conn.row_factory = sqlite3.Row # Enable dict-like access
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# Build query with filters
|
|
||||||
query = "SELECT * FROM transactions WHERE 1=1"
|
|
||||||
params = []
|
|
||||||
|
|
||||||
if account_id:
|
|
||||||
query += " AND accountId = ?"
|
|
||||||
params.append(account_id)
|
|
||||||
|
|
||||||
if date_from:
|
|
||||||
query += " AND transactionDate >= ?"
|
|
||||||
params.append(date_from)
|
|
||||||
|
|
||||||
if date_to:
|
|
||||||
query += " AND transactionDate <= ?"
|
|
||||||
params.append(date_to)
|
|
||||||
|
|
||||||
if min_amount is not None:
|
|
||||||
query += " AND transactionValue >= ?"
|
|
||||||
params.append(min_amount)
|
|
||||||
|
|
||||||
if max_amount is not None:
|
|
||||||
query += " AND transactionValue <= ?"
|
|
||||||
params.append(max_amount)
|
|
||||||
|
|
||||||
if search:
|
|
||||||
query += " AND description LIKE ?"
|
|
||||||
params.append(f"%{search}%")
|
|
||||||
|
|
||||||
# Add ordering and pagination
|
|
||||||
query += " ORDER BY transactionDate DESC"
|
|
||||||
|
|
||||||
if limit:
|
|
||||||
query += " LIMIT ?"
|
|
||||||
params.append(limit)
|
|
||||||
|
|
||||||
if offset:
|
|
||||||
query += " OFFSET ?"
|
|
||||||
params.append(offset)
|
|
||||||
|
|
||||||
try:
|
|
||||||
cursor.execute(query, params)
|
|
||||||
rows = cursor.fetchall()
|
|
||||||
|
|
||||||
# Convert to list of dicts and parse JSON fields
|
|
||||||
transactions = []
|
|
||||||
for row in rows:
|
|
||||||
transaction = dict(row)
|
|
||||||
if transaction["rawTransaction"]:
|
|
||||||
transaction["rawTransaction"] = json.loads(
|
|
||||||
transaction["rawTransaction"]
|
|
||||||
)
|
|
||||||
transactions.append(transaction)
|
|
||||||
|
|
||||||
conn.close()
|
|
||||||
return transactions
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
conn.close()
|
|
||||||
raise e
|
|
||||||
|
|
||||||
|
|
||||||
def get_balances(account_id=None):
|
|
||||||
"""Get latest balances from SQLite database"""
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
|
||||||
if not db_path.exists():
|
|
||||||
return []
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# Get latest balance for each account_id and type combination
|
|
||||||
query = """
|
|
||||||
SELECT * FROM balances b1
|
|
||||||
WHERE b1.timestamp = (
|
|
||||||
SELECT MAX(b2.timestamp)
|
|
||||||
FROM balances b2
|
|
||||||
WHERE b2.account_id = b1.account_id AND b2.type = b1.type
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
params = []
|
|
||||||
|
|
||||||
if account_id:
|
|
||||||
query += " AND b1.account_id = ?"
|
|
||||||
params.append(account_id)
|
|
||||||
|
|
||||||
query += " ORDER BY b1.account_id, b1.type"
|
|
||||||
|
|
||||||
try:
|
|
||||||
cursor.execute(query, params)
|
|
||||||
rows = cursor.fetchall()
|
|
||||||
|
|
||||||
balances = [dict(row) for row in rows]
|
|
||||||
conn.close()
|
|
||||||
return balances
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
conn.close()
|
|
||||||
raise e
|
|
||||||
|
|
||||||
|
|
||||||
def get_account_summary(account_id):
|
|
||||||
"""Get basic account info from transactions table (avoids GoCardless API call)"""
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
|
||||||
if not db_path.exists():
|
|
||||||
return None
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Get account info from most recent transaction
|
|
||||||
cursor.execute(
|
|
||||||
"""
|
|
||||||
SELECT DISTINCT accountId, institutionId, iban
|
|
||||||
FROM transactions
|
|
||||||
WHERE accountId = ?
|
|
||||||
ORDER BY transactionDate DESC
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
(account_id,),
|
|
||||||
)
|
|
||||||
|
|
||||||
row = cursor.fetchone()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
if row:
|
|
||||||
return dict(row)
|
|
||||||
return None
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
conn.close()
|
|
||||||
raise e
|
|
||||||
|
|
||||||
|
|
||||||
def get_transaction_count(account_id=None, **filters):
|
|
||||||
"""Get total count of transactions matching filters"""
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
|
||||||
if not db_path.exists():
|
|
||||||
return 0
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
query = "SELECT COUNT(*) FROM transactions WHERE 1=1"
|
|
||||||
params = []
|
|
||||||
|
|
||||||
if account_id:
|
|
||||||
query += " AND accountId = ?"
|
|
||||||
params.append(account_id)
|
|
||||||
|
|
||||||
# Add same filters as get_transactions
|
|
||||||
if filters.get("date_from"):
|
|
||||||
query += " AND transactionDate >= ?"
|
|
||||||
params.append(filters["date_from"])
|
|
||||||
|
|
||||||
if filters.get("date_to"):
|
|
||||||
query += " AND transactionDate <= ?"
|
|
||||||
params.append(filters["date_to"])
|
|
||||||
|
|
||||||
if filters.get("min_amount") is not None:
|
|
||||||
query += " AND transactionValue >= ?"
|
|
||||||
params.append(filters["min_amount"])
|
|
||||||
|
|
||||||
if filters.get("max_amount") is not None:
|
|
||||||
query += " AND transactionValue <= ?"
|
|
||||||
params.append(filters["max_amount"])
|
|
||||||
|
|
||||||
if filters.get("search"):
|
|
||||||
query += " AND description LIKE ?"
|
|
||||||
params.append(f"%{filters['search']}%")
|
|
||||||
|
|
||||||
try:
|
|
||||||
cursor.execute(query, params)
|
|
||||||
count = cursor.fetchone()[0]
|
|
||||||
conn.close()
|
|
||||||
return count
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
conn.close()
|
|
||||||
raise e
|
|
||||||
|
|
||||||
|
|
||||||
def persist_account(account_data: dict):
|
|
||||||
"""Persist account details to SQLite database"""
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
|
||||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# Create the accounts table if it doesn't exist
|
|
||||||
cursor.execute(
|
|
||||||
"""CREATE TABLE IF NOT EXISTS accounts (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
institution_id TEXT,
|
|
||||||
status TEXT,
|
|
||||||
iban TEXT,
|
|
||||||
name TEXT,
|
|
||||||
currency TEXT,
|
|
||||||
created DATETIME,
|
|
||||||
last_accessed DATETIME,
|
|
||||||
last_updated DATETIME
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create indexes for accounts table
|
|
||||||
cursor.execute(
|
|
||||||
"""CREATE INDEX IF NOT EXISTS idx_accounts_institution_id
|
|
||||||
ON accounts(institution_id)"""
|
|
||||||
)
|
|
||||||
cursor.execute(
|
|
||||||
"""CREATE INDEX IF NOT EXISTS idx_accounts_status
|
|
||||||
ON accounts(status)"""
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Insert or replace account data
|
|
||||||
cursor.execute(
|
|
||||||
"""INSERT OR REPLACE INTO accounts (
|
|
||||||
id,
|
|
||||||
institution_id,
|
|
||||||
status,
|
|
||||||
iban,
|
|
||||||
name,
|
|
||||||
currency,
|
|
||||||
created,
|
|
||||||
last_accessed,
|
|
||||||
last_updated
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
||||||
(
|
|
||||||
account_data["id"],
|
|
||||||
account_data["institution_id"],
|
|
||||||
account_data["status"],
|
|
||||||
account_data.get("iban"),
|
|
||||||
account_data.get("name"),
|
|
||||||
account_data.get("currency"),
|
|
||||||
account_data["created"],
|
|
||||||
account_data.get("last_accessed"),
|
|
||||||
account_data.get("last_updated", account_data["created"]),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
success(f"[{account_data['id']}] Account details persisted to database")
|
|
||||||
return account_data
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
conn.close()
|
|
||||||
raise e
|
|
||||||
|
|
||||||
|
|
||||||
def get_accounts(account_ids=None):
|
|
||||||
"""Get account details from SQLite database"""
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
|
||||||
if not db_path.exists():
|
|
||||||
return []
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
query = "SELECT * FROM accounts"
|
|
||||||
params = []
|
|
||||||
|
|
||||||
if account_ids:
|
|
||||||
placeholders = ",".join("?" * len(account_ids))
|
|
||||||
query += f" WHERE id IN ({placeholders})"
|
|
||||||
params.extend(account_ids)
|
|
||||||
|
|
||||||
query += " ORDER BY created DESC"
|
|
||||||
|
|
||||||
try:
|
|
||||||
cursor.execute(query, params)
|
|
||||||
rows = cursor.fetchall()
|
|
||||||
|
|
||||||
accounts = [dict(row) for row in rows]
|
|
||||||
conn.close()
|
|
||||||
return accounts
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
conn.close()
|
|
||||||
raise e
|
|
||||||
|
|
||||||
|
|
||||||
def get_account(account_id: str):
|
|
||||||
"""Get specific account details from SQLite database"""
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
|
||||||
if not db_path.exists():
|
|
||||||
return None
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
try:
|
|
||||||
cursor.execute("SELECT * FROM accounts WHERE id = ?", (account_id,))
|
|
||||||
row = cursor.fetchone()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
if row:
|
|
||||||
return dict(row)
|
|
||||||
return None
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
conn.close()
|
|
||||||
raise e
|
|
||||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
|||||||
import click
|
import click
|
||||||
|
|
||||||
from leggen.utils.config import load_config
|
from leggen.utils.config import load_config
|
||||||
|
from leggen.utils.paths import path_manager
|
||||||
from leggen.utils.text import error
|
from leggen.utils.text import error
|
||||||
|
|
||||||
cmd_folder = os.path.abspath(os.path.join(os.path.dirname(__file__), "commands"))
|
cmd_folder = os.path.abspath(os.path.join(os.path.dirname(__file__), "commands"))
|
||||||
@@ -77,7 +78,7 @@ class Group(click.Group):
|
|||||||
"-c",
|
"-c",
|
||||||
"--config",
|
"--config",
|
||||||
type=click.Path(dir_okay=False),
|
type=click.Path(dir_okay=False),
|
||||||
default=Path.home() / ".config" / "leggen" / "config.toml",
|
default=lambda: str(path_manager.get_config_file_path()),
|
||||||
show_default=True,
|
show_default=True,
|
||||||
callback=load_config,
|
callback=load_config,
|
||||||
is_eager=True,
|
is_eager=True,
|
||||||
@@ -86,13 +87,27 @@ class Group(click.Group):
|
|||||||
show_envvar=True,
|
show_envvar=True,
|
||||||
help="Path to TOML configuration file",
|
help="Path to TOML configuration file",
|
||||||
)
|
)
|
||||||
|
@click.option(
|
||||||
|
"--config-dir",
|
||||||
|
type=click.Path(exists=False, file_okay=False, path_type=Path),
|
||||||
|
envvar="LEGGEN_CONFIG_DIR",
|
||||||
|
show_envvar=True,
|
||||||
|
help="Directory containing configuration files (default: ~/.config/leggen)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--database",
|
||||||
|
type=click.Path(dir_okay=False, path_type=Path),
|
||||||
|
envvar="LEGGEN_DATABASE_PATH",
|
||||||
|
show_envvar=True,
|
||||||
|
help="Path to SQLite database file (default: <config-dir>/leggen.db)",
|
||||||
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--api-url",
|
"--api-url",
|
||||||
type=str,
|
type=str,
|
||||||
default="http://localhost:8000",
|
default="http://localhost:8000",
|
||||||
envvar="LEGGEND_API_URL",
|
envvar="LEGGEN_API_URL",
|
||||||
show_envvar=True,
|
show_envvar=True,
|
||||||
help="URL of the leggend API service",
|
help="URL of the leggen API service",
|
||||||
)
|
)
|
||||||
@click.group(
|
@click.group(
|
||||||
cls=Group,
|
cls=Group,
|
||||||
@@ -100,7 +115,7 @@ class Group(click.Group):
|
|||||||
)
|
)
|
||||||
@click.version_option(package_name="leggen")
|
@click.version_option(package_name="leggen")
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def cli(ctx: click.Context, api_url: str):
|
def cli(ctx: click.Context, config_dir: Path, database: Path, api_url: str):
|
||||||
"""
|
"""
|
||||||
Leggen: An Open Banking CLI
|
Leggen: An Open Banking CLI
|
||||||
"""
|
"""
|
||||||
@@ -109,5 +124,11 @@ def cli(ctx: click.Context, api_url: str):
|
|||||||
if "--help" in sys.argv[1:] or "-h" in sys.argv[1:]:
|
if "--help" in sys.argv[1:] or "-h" in sys.argv[1:]:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Set up path manager with user-provided paths
|
||||||
|
if config_dir:
|
||||||
|
path_manager.set_config_dir(config_dir)
|
||||||
|
if database:
|
||||||
|
path_manager.set_database_path(database)
|
||||||
|
|
||||||
# Store API URL in context for commands to use
|
# Store API URL in context for commands to use
|
||||||
ctx.obj["api_url"] = api_url
|
ctx.obj["api_url"] = api_url
|
||||||
|
|||||||
65
leggen/models/config.py
Normal file
65
leggen/models/config.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class GoCardlessConfig(BaseModel):
|
||||||
|
key: str = Field(..., description="GoCardless API key")
|
||||||
|
secret: str = Field(..., description="GoCardless API secret")
|
||||||
|
url: str = Field(
|
||||||
|
default="https://bankaccountdata.gocardless.com/api/v2",
|
||||||
|
description="GoCardless API URL",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseConfig(BaseModel):
|
||||||
|
sqlite: bool = Field(default=True, description="Enable SQLite database")
|
||||||
|
|
||||||
|
|
||||||
|
class DiscordNotificationConfig(BaseModel):
|
||||||
|
webhook: str = Field(..., description="Discord webhook URL")
|
||||||
|
enabled: bool = Field(default=True, description="Enable Discord notifications")
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramNotificationConfig(BaseModel):
|
||||||
|
token: str = Field(..., alias="api-key", description="Telegram bot token")
|
||||||
|
chat_id: int = Field(..., alias="chat-id", description="Telegram chat ID")
|
||||||
|
enabled: bool = Field(default=True, description="Enable Telegram notifications")
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationConfig(BaseModel):
|
||||||
|
discord: Optional[DiscordNotificationConfig] = None
|
||||||
|
telegram: Optional[TelegramNotificationConfig] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FilterConfig(BaseModel):
|
||||||
|
case_insensitive: Optional[List[str]] = Field(
|
||||||
|
default_factory=list, alias="case-insensitive"
|
||||||
|
)
|
||||||
|
case_sensitive: Optional[List[str]] = Field(
|
||||||
|
default_factory=list, alias="case-sensitive"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SyncScheduleConfig(BaseModel):
|
||||||
|
enabled: bool = Field(default=True, description="Enable sync scheduling")
|
||||||
|
hour: int = Field(default=3, ge=0, le=23, description="Hour to run sync (0-23)")
|
||||||
|
minute: int = Field(default=0, ge=0, le=59, description="Minute to run sync (0-59)")
|
||||||
|
cron: Optional[str] = Field(
|
||||||
|
default=None, description="Custom cron expression (overrides hour/minute)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulerConfig(BaseModel):
|
||||||
|
sync: SyncScheduleConfig = Field(default_factory=SyncScheduleConfig)
|
||||||
|
|
||||||
|
|
||||||
|
class Config(BaseModel):
|
||||||
|
gocardless: GoCardlessConfig
|
||||||
|
database: DatabaseConfig = Field(default_factory=DatabaseConfig)
|
||||||
|
notifications: Optional[NotificationConfig] = None
|
||||||
|
filters: Optional[FilterConfig] = None
|
||||||
|
scheduler: SchedulerConfig = Field(default_factory=SchedulerConfig)
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
validate_by_name = True
|
||||||
@@ -1,17 +1,20 @@
|
|||||||
from datetime import datetime
|
import json
|
||||||
from typing import List, Dict, Any, Optional
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from leggend.config import config
|
from leggen.services.transaction_processor import TransactionProcessor
|
||||||
import leggen.database.sqlite as sqlite_db
|
from leggen.utils.config import config
|
||||||
|
from leggen.utils.paths import path_manager
|
||||||
|
|
||||||
|
|
||||||
class DatabaseService:
|
class DatabaseService:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.db_config = config.database_config
|
self.db_config = config.database_config
|
||||||
self.sqlite_enabled = self.db_config.get("sqlite", True)
|
self.sqlite_enabled = self.db_config.get("sqlite", True)
|
||||||
|
self.transaction_processor = TransactionProcessor()
|
||||||
|
|
||||||
async def persist_balance(
|
async def persist_balance(
|
||||||
self, account_id: str, balance_data: Dict[str, Any]
|
self, account_id: str, balance_data: Dict[str, Any]
|
||||||
@@ -40,84 +43,14 @@ class DatabaseService:
|
|||||||
transaction_data: Dict[str, Any],
|
transaction_data: Dict[str, Any],
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""Process raw transaction data into standardized format"""
|
"""Process raw transaction data into standardized format"""
|
||||||
transactions = []
|
return self.transaction_processor.process_transactions(
|
||||||
|
account_id, account_info, transaction_data
|
||||||
# Process booked transactions
|
|
||||||
for transaction in transaction_data.get("transactions", {}).get("booked", []):
|
|
||||||
processed = self._process_single_transaction(
|
|
||||||
account_id, account_info, transaction, "booked"
|
|
||||||
)
|
|
||||||
transactions.append(processed)
|
|
||||||
|
|
||||||
# Process pending transactions
|
|
||||||
for transaction in transaction_data.get("transactions", {}).get("pending", []):
|
|
||||||
processed = self._process_single_transaction(
|
|
||||||
account_id, account_info, transaction, "pending"
|
|
||||||
)
|
|
||||||
transactions.append(processed)
|
|
||||||
|
|
||||||
return transactions
|
|
||||||
|
|
||||||
def _process_single_transaction(
|
|
||||||
self,
|
|
||||||
account_id: str,
|
|
||||||
account_info: Dict[str, Any],
|
|
||||||
transaction: Dict[str, Any],
|
|
||||||
status: str,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Process a single transaction into standardized format"""
|
|
||||||
# Extract dates
|
|
||||||
booked_date = transaction.get("bookingDateTime") or transaction.get(
|
|
||||||
"bookingDate"
|
|
||||||
)
|
)
|
||||||
value_date = transaction.get("valueDateTime") or transaction.get("valueDate")
|
|
||||||
|
|
||||||
if booked_date and value_date:
|
|
||||||
min_date = min(
|
|
||||||
datetime.fromisoformat(booked_date), datetime.fromisoformat(value_date)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
date_str = booked_date or value_date
|
|
||||||
if not date_str:
|
|
||||||
raise ValueError("No valid date found in transaction")
|
|
||||||
min_date = datetime.fromisoformat(date_str)
|
|
||||||
|
|
||||||
# Extract amount and currency
|
|
||||||
transaction_amount = transaction.get("transactionAmount", {})
|
|
||||||
amount = float(transaction_amount.get("amount", 0))
|
|
||||||
currency = transaction_amount.get("currency", "")
|
|
||||||
|
|
||||||
# Extract description
|
|
||||||
description = transaction.get(
|
|
||||||
"remittanceInformationUnstructured",
|
|
||||||
",".join(transaction.get("remittanceInformationUnstructuredArray", [])),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Extract transaction IDs - transactionId is now primary, internalTransactionId is reference
|
|
||||||
transaction_id = transaction.get("transactionId")
|
|
||||||
internal_transaction_id = transaction.get("internalTransactionId")
|
|
||||||
|
|
||||||
if not transaction_id:
|
|
||||||
raise ValueError("Transaction missing required transactionId field")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"accountId": account_id,
|
|
||||||
"transactionId": transaction_id,
|
|
||||||
"internalTransactionId": internal_transaction_id,
|
|
||||||
"institutionId": account_info["institution_id"],
|
|
||||||
"iban": account_info.get("iban", "N/A"),
|
|
||||||
"transactionDate": min_date,
|
|
||||||
"description": description,
|
|
||||||
"transactionValue": amount,
|
|
||||||
"transactionCurrency": currency,
|
|
||||||
"transactionStatus": status,
|
|
||||||
"rawTransaction": transaction,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def get_transactions_from_db(
|
async def get_transactions_from_db(
|
||||||
self,
|
self,
|
||||||
account_id: Optional[str] = None,
|
account_id: Optional[str] = None,
|
||||||
limit: Optional[int] = 100,
|
limit: Optional[int] = None, # None means no limit, used for stats
|
||||||
offset: Optional[int] = 0,
|
offset: Optional[int] = 0,
|
||||||
date_from: Optional[str] = None,
|
date_from: Optional[str] = None,
|
||||||
date_to: Optional[str] = None,
|
date_to: Optional[str] = None,
|
||||||
@@ -131,9 +64,9 @@ class DatabaseService:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
transactions = sqlite_db.get_transactions(
|
transactions = self._get_transactions(
|
||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
limit=limit or 100,
|
limit=limit, # Pass limit as-is, None means no limit
|
||||||
offset=offset or 0,
|
offset=offset or 0,
|
||||||
date_from=date_from,
|
date_from=date_from,
|
||||||
date_to=date_to,
|
date_to=date_to,
|
||||||
@@ -171,7 +104,7 @@ class DatabaseService:
|
|||||||
# Remove None values
|
# Remove None values
|
||||||
filters = {k: v for k, v in filters.items() if v is not None}
|
filters = {k: v for k, v in filters.items() if v is not None}
|
||||||
|
|
||||||
count = sqlite_db.get_transaction_count(account_id=account_id, **filters)
|
count = self._get_transaction_count(account_id=account_id, **filters)
|
||||||
logger.debug(f"Total transaction count: {count}")
|
logger.debug(f"Total transaction count: {count}")
|
||||||
return count
|
return count
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -187,13 +120,31 @@ class DatabaseService:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
balances = sqlite_db.get_balances(account_id=account_id)
|
balances = self._get_balances(account_id=account_id)
|
||||||
logger.debug(f"Retrieved {len(balances)} balances from database")
|
logger.debug(f"Retrieved {len(balances)} balances from database")
|
||||||
return balances
|
return balances
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get balances from database: {e}")
|
logger.error(f"Failed to get balances from database: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
async def get_historical_balances_from_db(
|
||||||
|
self, account_id: Optional[str] = None, days: int = 365
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Get historical balance progression from SQLite database"""
|
||||||
|
if not self.sqlite_enabled:
|
||||||
|
logger.warning("SQLite database disabled, cannot read historical balances")
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
balances = self._get_historical_balances(account_id=account_id, days=days)
|
||||||
|
logger.debug(
|
||||||
|
f"Retrieved {len(balances)} historical balance points from database"
|
||||||
|
)
|
||||||
|
return balances
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get historical balances from database: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
async def get_account_summary_from_db(
|
async def get_account_summary_from_db(
|
||||||
self, account_id: str
|
self, account_id: str
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
@@ -202,7 +153,7 @@ class DatabaseService:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
summary = sqlite_db.get_account_summary(account_id)
|
summary = self._get_account_summary(account_id)
|
||||||
if summary:
|
if summary:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Retrieved account summary from database for {account_id}"
|
f"Retrieved account summary from database for {account_id}"
|
||||||
@@ -229,7 +180,7 @@ class DatabaseService:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
accounts = sqlite_db.get_accounts(account_ids=account_ids)
|
accounts = self._get_accounts(account_ids=account_ids)
|
||||||
logger.debug(f"Retrieved {len(accounts)} accounts from database")
|
logger.debug(f"Retrieved {len(accounts)} accounts from database")
|
||||||
return accounts
|
return accounts
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -245,7 +196,7 @@ class DatabaseService:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
account = sqlite_db.get_account(account_id)
|
account = self._get_account(account_id)
|
||||||
if account:
|
if account:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Retrieved account details from database for {account_id}"
|
f"Retrieved account details from database for {account_id}"
|
||||||
@@ -280,9 +231,7 @@ class DatabaseService:
|
|||||||
|
|
||||||
async def _check_balance_timestamp_migration_needed(self) -> bool:
|
async def _check_balance_timestamp_migration_needed(self) -> bool:
|
||||||
"""Check if balance timestamps need migration"""
|
"""Check if balance timestamps need migration"""
|
||||||
from pathlib import Path
|
db_path = path_manager.get_database_path()
|
||||||
|
|
||||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
|
||||||
if not db_path.exists():
|
if not db_path.exists():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -310,9 +259,7 @@ class DatabaseService:
|
|||||||
|
|
||||||
async def _migrate_balance_timestamps(self):
|
async def _migrate_balance_timestamps(self):
|
||||||
"""Convert all Unix timestamps to datetime strings"""
|
"""Convert all Unix timestamps to datetime strings"""
|
||||||
from pathlib import Path
|
db_path = path_manager.get_database_path()
|
||||||
|
|
||||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
|
||||||
if not db_path.exists():
|
if not db_path.exists():
|
||||||
logger.warning("Database file not found, skipping migration")
|
logger.warning("Database file not found, skipping migration")
|
||||||
return
|
return
|
||||||
@@ -399,9 +346,7 @@ class DatabaseService:
|
|||||||
|
|
||||||
async def _check_null_transaction_ids_migration_needed(self) -> bool:
|
async def _check_null_transaction_ids_migration_needed(self) -> bool:
|
||||||
"""Check if null transaction IDs need migration"""
|
"""Check if null transaction IDs need migration"""
|
||||||
from pathlib import Path
|
db_path = path_manager.get_database_path()
|
||||||
|
|
||||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
|
||||||
if not db_path.exists():
|
if not db_path.exists():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -429,9 +374,8 @@ class DatabaseService:
|
|||||||
async def _migrate_null_transaction_ids(self):
|
async def _migrate_null_transaction_ids(self):
|
||||||
"""Populate null internalTransactionId fields using transactionId from raw data"""
|
"""Populate null internalTransactionId fields using transactionId from raw data"""
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
db_path = path_manager.get_database_path()
|
||||||
if not db_path.exists():
|
if not db_path.exists():
|
||||||
logger.warning("Database file not found, skipping migration")
|
logger.warning("Database file not found, skipping migration")
|
||||||
return
|
return
|
||||||
@@ -538,9 +482,7 @@ class DatabaseService:
|
|||||||
|
|
||||||
async def _check_composite_key_migration_needed(self) -> bool:
|
async def _check_composite_key_migration_needed(self) -> bool:
|
||||||
"""Check if composite key migration is needed"""
|
"""Check if composite key migration is needed"""
|
||||||
from pathlib import Path
|
db_path = path_manager.get_database_path()
|
||||||
|
|
||||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
|
||||||
if not db_path.exists():
|
if not db_path.exists():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -548,6 +490,14 @@ class DatabaseService:
|
|||||||
conn = sqlite3.connect(str(db_path))
|
conn = sqlite3.connect(str(db_path))
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check if transactions table exists
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='transactions'"
|
||||||
|
)
|
||||||
|
if not cursor.fetchone():
|
||||||
|
conn.close()
|
||||||
|
return False
|
||||||
|
|
||||||
# Check if transactions table has the old primary key structure
|
# Check if transactions table has the old primary key structure
|
||||||
cursor.execute("PRAGMA table_info(transactions)")
|
cursor.execute("PRAGMA table_info(transactions)")
|
||||||
columns = cursor.fetchall()
|
columns = cursor.fetchall()
|
||||||
@@ -558,26 +508,19 @@ class DatabaseService:
|
|||||||
for col in columns
|
for col in columns
|
||||||
)
|
)
|
||||||
|
|
||||||
# If internalTransactionId is still the primary key, migration is needed
|
# Check if we have the new composite primary key structure
|
||||||
if internal_transaction_id_is_pk:
|
has_composite_key = any(
|
||||||
# Check if there are duplicate (accountId, transactionId) pairs
|
col[1] in ["accountId", "transactionId"]
|
||||||
cursor.execute("""
|
and col[5] == 1 # col[5] is pk flag
|
||||||
SELECT COUNT(*) as duplicates
|
for col in columns
|
||||||
FROM (
|
)
|
||||||
SELECT accountId, json_extract(rawTransaction, '$.transactionId') as transactionId, COUNT(*) as cnt
|
|
||||||
FROM transactions
|
conn.close()
|
||||||
WHERE json_extract(rawTransaction, '$.transactionId') IS NOT NULL
|
|
||||||
GROUP BY accountId, json_extract(rawTransaction, '$.transactionId')
|
# Migration is needed if:
|
||||||
HAVING COUNT(*) > 1
|
# 1. internalTransactionId is still the primary key (old structure), OR
|
||||||
)
|
# 2. We don't have the new composite key structure yet
|
||||||
""")
|
return internal_transaction_id_is_pk or not has_composite_key
|
||||||
duplicates = cursor.fetchone()[0]
|
|
||||||
conn.close()
|
|
||||||
return duplicates > 0
|
|
||||||
else:
|
|
||||||
# Migration already completed
|
|
||||||
conn.close()
|
|
||||||
return False
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to check composite key migration status: {e}")
|
logger.error(f"Failed to check composite key migration status: {e}")
|
||||||
@@ -585,9 +528,7 @@ class DatabaseService:
|
|||||||
|
|
||||||
async def _migrate_to_composite_key(self):
|
async def _migrate_to_composite_key(self):
|
||||||
"""Migrate transactions table to use composite primary key (accountId, transactionId)"""
|
"""Migrate transactions table to use composite primary key (accountId, transactionId)"""
|
||||||
from pathlib import Path
|
db_path = path_manager.get_database_path()
|
||||||
|
|
||||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
|
||||||
if not db_path.exists():
|
if not db_path.exists():
|
||||||
logger.warning("Database file not found, skipping migration")
|
logger.warning("Database file not found, skipping migration")
|
||||||
return
|
return
|
||||||
@@ -703,10 +644,7 @@ class DatabaseService:
|
|||||||
try:
|
try:
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
from pathlib import Path
|
db_path = path_manager.get_database_path()
|
||||||
|
|
||||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
|
||||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
conn = sqlite3.connect(str(db_path))
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
@@ -782,13 +720,10 @@ class DatabaseService:
|
|||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""Persist transactions to SQLite"""
|
"""Persist transactions to SQLite"""
|
||||||
try:
|
try:
|
||||||
import sqlite3
|
|
||||||
import json
|
import json
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
from pathlib import Path
|
db_path = path_manager.get_database_path()
|
||||||
|
|
||||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
|
||||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
conn = sqlite3.connect(str(db_path))
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
@@ -887,13 +822,8 @@ class DatabaseService:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Persist account details to SQLite"""
|
"""Persist account details to SQLite"""
|
||||||
try:
|
try:
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
|
||||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Use the sqlite_db module function
|
# Use the sqlite_db module function
|
||||||
sqlite_db.persist_account(account_data)
|
self._persist_account(account_data)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Persisted account details to SQLite for account {account_data['id']}"
|
f"Persisted account details to SQLite for account {account_data['id']}"
|
||||||
@@ -901,3 +831,500 @@ class DatabaseService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to persist account details to SQLite: {e}")
|
logger.error(f"Failed to persist account details to SQLite: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
def _get_transactions(
|
||||||
|
self,
|
||||||
|
account_id=None,
|
||||||
|
limit=100,
|
||||||
|
offset=0,
|
||||||
|
date_from=None,
|
||||||
|
date_to=None,
|
||||||
|
min_amount=None,
|
||||||
|
max_amount=None,
|
||||||
|
search=None,
|
||||||
|
):
|
||||||
|
"""Get transactions from SQLite database with optional filtering"""
|
||||||
|
db_path = path_manager.get_database_path()
|
||||||
|
if not db_path.exists():
|
||||||
|
return []
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.row_factory = sqlite3.Row # Enable dict-like access
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Build query with filters
|
||||||
|
query = "SELECT * FROM transactions WHERE 1=1"
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if account_id:
|
||||||
|
query += " AND accountId = ?"
|
||||||
|
params.append(account_id)
|
||||||
|
|
||||||
|
if date_from:
|
||||||
|
query += " AND transactionDate >= ?"
|
||||||
|
params.append(date_from)
|
||||||
|
|
||||||
|
if date_to:
|
||||||
|
query += " AND transactionDate <= ?"
|
||||||
|
params.append(date_to)
|
||||||
|
|
||||||
|
if min_amount is not None:
|
||||||
|
query += " AND transactionValue >= ?"
|
||||||
|
params.append(min_amount)
|
||||||
|
|
||||||
|
if max_amount is not None:
|
||||||
|
query += " AND transactionValue <= ?"
|
||||||
|
params.append(max_amount)
|
||||||
|
|
||||||
|
if search:
|
||||||
|
query += " AND description LIKE ?"
|
||||||
|
params.append(f"%{search}%")
|
||||||
|
|
||||||
|
# Add ordering and pagination
|
||||||
|
query += " ORDER BY transactionDate DESC"
|
||||||
|
|
||||||
|
if limit:
|
||||||
|
query += " LIMIT ?"
|
||||||
|
params.append(limit)
|
||||||
|
|
||||||
|
if offset:
|
||||||
|
query += " OFFSET ?"
|
||||||
|
params.append(offset)
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute(query, params)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
# Convert to list of dicts and parse JSON fields
|
||||||
|
transactions = []
|
||||||
|
for row in rows:
|
||||||
|
transaction = dict(row)
|
||||||
|
if transaction["rawTransaction"]:
|
||||||
|
transaction["rawTransaction"] = json.loads(
|
||||||
|
transaction["rawTransaction"]
|
||||||
|
)
|
||||||
|
transactions.append(transaction)
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return transactions
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.close()
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def _get_balances(self, account_id=None):
|
||||||
|
"""Get latest balances from SQLite database"""
|
||||||
|
db_path = path_manager.get_database_path()
|
||||||
|
if not db_path.exists():
|
||||||
|
return []
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Get latest balance for each account_id and type combination
|
||||||
|
query = """
|
||||||
|
SELECT * FROM balances b1
|
||||||
|
WHERE b1.timestamp = (
|
||||||
|
SELECT MAX(b2.timestamp)
|
||||||
|
FROM balances b2
|
||||||
|
WHERE b2.account_id = b1.account_id AND b2.type = b1.type
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if account_id:
|
||||||
|
query += " AND b1.account_id = ?"
|
||||||
|
params.append(account_id)
|
||||||
|
|
||||||
|
query += " ORDER BY b1.account_id, b1.type"
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute(query, params)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
balances = [dict(row) for row in rows]
|
||||||
|
conn.close()
|
||||||
|
return balances
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.close()
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def _get_account_summary(self, account_id):
|
||||||
|
"""Get basic account info from transactions table (avoids GoCardless API call)"""
|
||||||
|
db_path = path_manager.get_database_path()
|
||||||
|
if not db_path.exists():
|
||||||
|
return None
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get account info from most recent transaction
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT accountId, institutionId, iban
|
||||||
|
FROM transactions
|
||||||
|
WHERE accountId = ?
|
||||||
|
ORDER BY transactionDate DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(account_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
row = cursor.fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if row:
|
||||||
|
return dict(row)
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.close()
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def _get_transaction_count(self, account_id=None, **filters):
|
||||||
|
"""Get total count of transactions matching filters"""
|
||||||
|
db_path = path_manager.get_database_path()
|
||||||
|
if not db_path.exists():
|
||||||
|
return 0
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
query = "SELECT COUNT(*) FROM transactions WHERE 1=1"
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if account_id:
|
||||||
|
query += " AND accountId = ?"
|
||||||
|
params.append(account_id)
|
||||||
|
|
||||||
|
# Add same filters as get_transactions
|
||||||
|
if filters.get("date_from"):
|
||||||
|
query += " AND transactionDate >= ?"
|
||||||
|
params.append(filters["date_from"])
|
||||||
|
|
||||||
|
if filters.get("date_to"):
|
||||||
|
query += " AND transactionDate <= ?"
|
||||||
|
params.append(filters["date_to"])
|
||||||
|
|
||||||
|
if filters.get("min_amount") is not None:
|
||||||
|
query += " AND transactionValue >= ?"
|
||||||
|
params.append(filters["min_amount"])
|
||||||
|
|
||||||
|
if filters.get("max_amount") is not None:
|
||||||
|
query += " AND transactionValue <= ?"
|
||||||
|
params.append(filters["max_amount"])
|
||||||
|
|
||||||
|
if filters.get("search"):
|
||||||
|
query += " AND description LIKE ?"
|
||||||
|
params.append(f"%{filters['search']}%")
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute(query, params)
|
||||||
|
count = cursor.fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
return count
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.close()
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def _persist_account(self, account_data: dict):
|
||||||
|
"""Persist account details to SQLite database"""
|
||||||
|
db_path = path_manager.get_database_path()
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Create the accounts table if it doesn't exist
|
||||||
|
cursor.execute(
|
||||||
|
"""CREATE TABLE IF NOT EXISTS accounts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
institution_id TEXT,
|
||||||
|
status TEXT,
|
||||||
|
iban TEXT,
|
||||||
|
name TEXT,
|
||||||
|
currency TEXT,
|
||||||
|
created DATETIME,
|
||||||
|
last_accessed DATETIME,
|
||||||
|
last_updated DATETIME
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create indexes for accounts table
|
||||||
|
cursor.execute(
|
||||||
|
"""CREATE INDEX IF NOT EXISTS idx_accounts_institution_id
|
||||||
|
ON accounts(institution_id)"""
|
||||||
|
)
|
||||||
|
cursor.execute(
|
||||||
|
"""CREATE INDEX IF NOT EXISTS idx_accounts_status
|
||||||
|
ON accounts(status)"""
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Insert or replace account data
|
||||||
|
cursor.execute(
|
||||||
|
"""INSERT OR REPLACE INTO accounts (
|
||||||
|
id,
|
||||||
|
institution_id,
|
||||||
|
status,
|
||||||
|
iban,
|
||||||
|
name,
|
||||||
|
currency,
|
||||||
|
created,
|
||||||
|
last_accessed,
|
||||||
|
last_updated
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(
|
||||||
|
account_data["id"],
|
||||||
|
account_data["institution_id"],
|
||||||
|
account_data["status"],
|
||||||
|
account_data.get("iban"),
|
||||||
|
account_data.get("name"),
|
||||||
|
account_data.get("currency"),
|
||||||
|
account_data["created"],
|
||||||
|
account_data.get("last_accessed"),
|
||||||
|
account_data.get("last_updated", account_data["created"]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return account_data
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.close()
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def _get_accounts(self, account_ids=None):
|
||||||
|
"""Get account details from SQLite database"""
|
||||||
|
db_path = path_manager.get_database_path()
|
||||||
|
if not db_path.exists():
|
||||||
|
return []
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
query = "SELECT * FROM accounts"
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if account_ids:
|
||||||
|
placeholders = ",".join("?" * len(account_ids))
|
||||||
|
query += f" WHERE id IN ({placeholders})"
|
||||||
|
params.extend(account_ids)
|
||||||
|
|
||||||
|
query += " ORDER BY created DESC"
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute(query, params)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
accounts = [dict(row) for row in rows]
|
||||||
|
conn.close()
|
||||||
|
return accounts
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.close()
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def _get_account(self, account_id: str):
|
||||||
|
"""Get specific account details from SQLite database"""
|
||||||
|
db_path = path_manager.get_database_path()
|
||||||
|
if not db_path.exists():
|
||||||
|
return None
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute("SELECT * FROM accounts WHERE id = ?", (account_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if row:
|
||||||
|
return dict(row)
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.close()
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def _get_historical_balances(self, account_id=None, days=365):
|
||||||
|
"""Get historical balance progression based on transaction history"""
|
||||||
|
db_path = path_manager.get_database_path()
|
||||||
|
if not db_path.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cutoff_date = (datetime.now() - timedelta(days=days)).date().isoformat()
|
||||||
|
today_date = datetime.now().date().isoformat()
|
||||||
|
|
||||||
|
# Single SQL query to generate historical balances using window functions
|
||||||
|
query = """
|
||||||
|
WITH RECURSIVE date_series AS (
|
||||||
|
-- Generate weekly dates from cutoff_date to today
|
||||||
|
SELECT date(?) as ref_date
|
||||||
|
UNION ALL
|
||||||
|
SELECT date(ref_date, '+7 days')
|
||||||
|
FROM date_series
|
||||||
|
WHERE ref_date < date(?)
|
||||||
|
),
|
||||||
|
current_balances AS (
|
||||||
|
-- Get current balance for each account/type
|
||||||
|
SELECT account_id, type, amount, currency
|
||||||
|
FROM balances b1
|
||||||
|
WHERE b1.timestamp = (
|
||||||
|
SELECT MAX(b2.timestamp)
|
||||||
|
FROM balances b2
|
||||||
|
WHERE b2.account_id = b1.account_id AND b2.type = b1.type
|
||||||
|
)
|
||||||
|
{account_filter}
|
||||||
|
AND b1.type = 'closingBooked' -- Focus on closingBooked for charts
|
||||||
|
),
|
||||||
|
historical_points AS (
|
||||||
|
-- Calculate balance at each weekly point by subtracting future transactions
|
||||||
|
SELECT
|
||||||
|
cb.account_id,
|
||||||
|
cb.type as balance_type,
|
||||||
|
cb.currency,
|
||||||
|
ds.ref_date,
|
||||||
|
cb.amount - COALESCE(
|
||||||
|
(SELECT SUM(t.transactionValue)
|
||||||
|
FROM transactions t
|
||||||
|
WHERE t.accountId = cb.account_id
|
||||||
|
AND date(t.transactionDate) > ds.ref_date), 0
|
||||||
|
) as balance_amount
|
||||||
|
FROM current_balances cb
|
||||||
|
CROSS JOIN date_series ds
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
account_id || '_' || balance_type || '_' || ref_date as id,
|
||||||
|
account_id,
|
||||||
|
balance_amount,
|
||||||
|
balance_type,
|
||||||
|
currency,
|
||||||
|
ref_date as reference_date
|
||||||
|
FROM historical_points
|
||||||
|
ORDER BY account_id, ref_date
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Build parameters and account filter
|
||||||
|
params = [cutoff_date, today_date]
|
||||||
|
if account_id:
|
||||||
|
account_filter = "AND b1.account_id = ?"
|
||||||
|
params.append(account_id)
|
||||||
|
else:
|
||||||
|
account_filter = ""
|
||||||
|
|
||||||
|
# Format the query with conditional filter
|
||||||
|
formatted_query = query.format(account_filter=account_filter)
|
||||||
|
|
||||||
|
cursor.execute(formatted_query, params)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.close()
|
||||||
|
raise e
|
||||||
|
|
||||||
|
async def get_monthly_transaction_stats_from_db(
|
||||||
|
self,
|
||||||
|
account_id: Optional[str] = None,
|
||||||
|
date_from: Optional[str] = None,
|
||||||
|
date_to: Optional[str] = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Get monthly transaction statistics aggregated by the database"""
|
||||||
|
if not self.sqlite_enabled:
|
||||||
|
logger.warning("SQLite database disabled, cannot read monthly stats")
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
monthly_stats = self._get_monthly_transaction_stats(
|
||||||
|
account_id=account_id,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"Retrieved {len(monthly_stats)} monthly stat points from database"
|
||||||
|
)
|
||||||
|
return monthly_stats
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get monthly transaction stats from database: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _get_monthly_transaction_stats(
|
||||||
|
self,
|
||||||
|
account_id: Optional[str] = None,
|
||||||
|
date_from: Optional[str] = None,
|
||||||
|
date_to: Optional[str] = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Get monthly transaction statistics from SQLite database"""
|
||||||
|
db_path = path_manager.get_database_path()
|
||||||
|
if not db_path.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# SQL query to aggregate transactions by month
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
strftime('%Y-%m', transactionDate) as month,
|
||||||
|
COALESCE(SUM(CASE WHEN transactionValue > 0 THEN transactionValue ELSE 0 END), 0) as income,
|
||||||
|
COALESCE(SUM(CASE WHEN transactionValue < 0 THEN ABS(transactionValue) ELSE 0 END), 0) as expenses,
|
||||||
|
COALESCE(SUM(transactionValue), 0) as net
|
||||||
|
FROM transactions
|
||||||
|
WHERE 1=1
|
||||||
|
"""
|
||||||
|
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if account_id:
|
||||||
|
query += " AND accountId = ?"
|
||||||
|
params.append(account_id)
|
||||||
|
|
||||||
|
if date_from:
|
||||||
|
query += " AND transactionDate >= ?"
|
||||||
|
params.append(date_from)
|
||||||
|
|
||||||
|
if date_to:
|
||||||
|
query += " AND transactionDate <= ?"
|
||||||
|
params.append(date_to)
|
||||||
|
|
||||||
|
query += """
|
||||||
|
GROUP BY strftime('%Y-%m', transactionDate)
|
||||||
|
ORDER BY month ASC
|
||||||
|
"""
|
||||||
|
|
||||||
|
cursor.execute(query, params)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
# Convert to desired format with proper month display
|
||||||
|
monthly_stats = []
|
||||||
|
for row in rows:
|
||||||
|
# Convert YYYY-MM to display format like "Mar 2024"
|
||||||
|
year, month_num = row["month"].split("-")
|
||||||
|
month_date = datetime.strptime(f"{year}-{month_num}-01", "%Y-%m-%d")
|
||||||
|
display_month = month_date.strftime("%b %Y")
|
||||||
|
|
||||||
|
monthly_stats.append(
|
||||||
|
{
|
||||||
|
"month": display_month,
|
||||||
|
"income": round(row["income"], 2),
|
||||||
|
"expenses": round(row["expenses"], 2),
|
||||||
|
"net": round(row["net"], 2),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return monthly_stats
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.close()
|
||||||
|
raise e
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import json
|
import json
|
||||||
import httpx
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any, List
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from leggend.config import config
|
from leggen.utils.config import config
|
||||||
|
from leggen.utils.paths import path_manager
|
||||||
|
|
||||||
|
|
||||||
def _log_rate_limits(response):
|
def _log_rate_limits(response):
|
||||||
@@ -39,8 +40,8 @@ class GoCardlessService:
|
|||||||
if self._token:
|
if self._token:
|
||||||
return self._token
|
return self._token
|
||||||
|
|
||||||
# Use ~/.config/leggen for consistency with main config
|
# Use path manager for auth file
|
||||||
auth_file = Path.home() / ".config" / "leggen" / "auth.json"
|
auth_file = path_manager.get_auth_file_path()
|
||||||
|
|
||||||
if auth_file.exists():
|
if auth_file.exists():
|
||||||
try:
|
try:
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
from typing import List, Dict, Any
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from leggend.config import config
|
from leggen.utils.config import config
|
||||||
|
|
||||||
|
|
||||||
class NotificationService:
|
class NotificationService:
|
||||||
@@ -110,33 +110,77 @@ class NotificationService:
|
|||||||
telegram_config = self.notifications_config.get("telegram", {})
|
telegram_config = self.notifications_config.get("telegram", {})
|
||||||
return bool(
|
return bool(
|
||||||
telegram_config.get("token")
|
telegram_config.get("token")
|
||||||
or telegram_config.get("api-key")
|
and telegram_config.get("chat_id")
|
||||||
and (telegram_config.get("chat_id") or telegram_config.get("chat-id"))
|
|
||||||
and telegram_config.get("enabled", True)
|
and telegram_config.get("enabled", True)
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _send_discord_notifications(
|
async def _send_discord_notifications(
|
||||||
self, transactions: List[Dict[str, Any]]
|
self, transactions: List[Dict[str, Any]]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Send Discord notifications - placeholder implementation"""
|
"""Send Discord notifications for transactions"""
|
||||||
# Would import and use leggen.notifications.discord
|
try:
|
||||||
logger.info(f"Sending {len(transactions)} transaction notifications to Discord")
|
import click
|
||||||
|
|
||||||
|
from leggen.notifications.discord import send_transactions_message
|
||||||
|
|
||||||
|
# Create a mock context with the webhook
|
||||||
|
ctx = click.Context(click.Command("notifications"))
|
||||||
|
ctx.obj = {
|
||||||
|
"notifications": {
|
||||||
|
"discord": {
|
||||||
|
"webhook": self.notifications_config.get("discord", {}).get(
|
||||||
|
"webhook"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Send transaction notifications using the actual implementation
|
||||||
|
send_transactions_message(ctx, transactions)
|
||||||
|
logger.info(
|
||||||
|
f"Sent {len(transactions)} transaction notifications to Discord"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send Discord transaction notifications: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
async def _send_telegram_notifications(
|
async def _send_telegram_notifications(
|
||||||
self, transactions: List[Dict[str, Any]]
|
self, transactions: List[Dict[str, Any]]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Send Telegram notifications - placeholder implementation"""
|
"""Send Telegram notifications for transactions"""
|
||||||
# Would import and use leggen.notifications.telegram
|
try:
|
||||||
logger.info(
|
import click
|
||||||
f"Sending {len(transactions)} transaction notifications to Telegram"
|
|
||||||
)
|
from leggen.notifications.telegram import send_transaction_message
|
||||||
|
|
||||||
|
# Create a mock context with the telegram config
|
||||||
|
ctx = click.Context(click.Command("notifications"))
|
||||||
|
telegram_config = self.notifications_config.get("telegram", {})
|
||||||
|
ctx.obj = {
|
||||||
|
"notifications": {
|
||||||
|
"telegram": {
|
||||||
|
"api-key": telegram_config.get("token"),
|
||||||
|
"chat-id": telegram_config.get("chat_id"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Send transaction notifications using the actual implementation
|
||||||
|
send_transaction_message(ctx, transactions)
|
||||||
|
logger.info(
|
||||||
|
f"Sent {len(transactions)} transaction notifications to Telegram"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send Telegram transaction notifications: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
async def _send_discord_test(self, message: str) -> None:
|
async def _send_discord_test(self, message: str) -> None:
|
||||||
"""Send Discord test notification"""
|
"""Send Discord test notification"""
|
||||||
try:
|
try:
|
||||||
from leggen.notifications.discord import send_expire_notification
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
|
from leggen.notifications.discord import send_expire_notification
|
||||||
|
|
||||||
# Create a mock context with the webhook
|
# Create a mock context with the webhook
|
||||||
ctx = click.Context(click.Command("test"))
|
ctx = click.Context(click.Command("test"))
|
||||||
ctx.obj = {
|
ctx.obj = {
|
||||||
@@ -165,19 +209,18 @@ class NotificationService:
|
|||||||
async def _send_telegram_test(self, message: str) -> None:
|
async def _send_telegram_test(self, message: str) -> None:
|
||||||
"""Send Telegram test notification"""
|
"""Send Telegram test notification"""
|
||||||
try:
|
try:
|
||||||
from leggen.notifications.telegram import send_expire_notification
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
|
from leggen.notifications.telegram import send_expire_notification
|
||||||
|
|
||||||
# Create a mock context with the telegram config
|
# Create a mock context with the telegram config
|
||||||
ctx = click.Context(click.Command("test"))
|
ctx = click.Context(click.Command("test"))
|
||||||
telegram_config = self.notifications_config.get("telegram", {})
|
telegram_config = self.notifications_config.get("telegram", {})
|
||||||
ctx.obj = {
|
ctx.obj = {
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"api-key": telegram_config.get("token")
|
"api-key": telegram_config.get("token"),
|
||||||
or telegram_config.get("api-key"),
|
"chat-id": telegram_config.get("chat_id"),
|
||||||
"chat-id": telegram_config.get("chat_id")
|
|
||||||
or telegram_config.get("chat-id"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,8 +240,52 @@ class NotificationService:
|
|||||||
|
|
||||||
async def _send_discord_expiry(self, notification_data: Dict[str, Any]) -> None:
|
async def _send_discord_expiry(self, notification_data: Dict[str, Any]) -> None:
|
||||||
"""Send Discord expiry notification"""
|
"""Send Discord expiry notification"""
|
||||||
logger.info(f"Sending Discord expiry notification: {notification_data}")
|
try:
|
||||||
|
import click
|
||||||
|
|
||||||
|
from leggen.notifications.discord import send_expire_notification
|
||||||
|
|
||||||
|
# Create a mock context with the webhook
|
||||||
|
ctx = click.Context(click.Command("expiry"))
|
||||||
|
ctx.obj = {
|
||||||
|
"notifications": {
|
||||||
|
"discord": {
|
||||||
|
"webhook": self.notifications_config.get("discord", {}).get(
|
||||||
|
"webhook"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Send expiry notification using the actual implementation
|
||||||
|
send_expire_notification(ctx, notification_data)
|
||||||
|
logger.info(f"Sent Discord expiry notification: {notification_data}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send Discord expiry notification: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
async def _send_telegram_expiry(self, notification_data: Dict[str, Any]) -> None:
|
async def _send_telegram_expiry(self, notification_data: Dict[str, Any]) -> None:
|
||||||
"""Send Telegram expiry notification"""
|
"""Send Telegram expiry notification"""
|
||||||
logger.info(f"Sending Telegram expiry notification: {notification_data}")
|
try:
|
||||||
|
import click
|
||||||
|
|
||||||
|
from leggen.notifications.telegram import send_expire_notification
|
||||||
|
|
||||||
|
# Create a mock context with the telegram config
|
||||||
|
ctx = click.Context(click.Command("expiry"))
|
||||||
|
telegram_config = self.notifications_config.get("telegram", {})
|
||||||
|
ctx.obj = {
|
||||||
|
"notifications": {
|
||||||
|
"telegram": {
|
||||||
|
"api-key": telegram_config.get("token"),
|
||||||
|
"chat-id": telegram_config.get("chat_id"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Send expiry notification using the actual implementation
|
||||||
|
send_expire_notification(ctx, notification_data)
|
||||||
|
logger.info(f"Sent Telegram expiry notification: {notification_data}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send Telegram expiry notification: {e}")
|
||||||
|
raise
|
||||||
@@ -3,10 +3,10 @@ from typing import List
|
|||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from leggend.api.models.sync import SyncResult, SyncStatus
|
from leggen.api.models.sync import SyncResult, SyncStatus
|
||||||
from leggend.services.gocardless_service import GoCardlessService
|
from leggen.services.database_service import DatabaseService
|
||||||
from leggend.services.database_service import DatabaseService
|
from leggen.services.gocardless_service import GoCardlessService
|
||||||
from leggend.services.notification_service import NotificationService
|
from leggen.services.notification_service import NotificationService
|
||||||
|
|
||||||
|
|
||||||
class SyncService:
|
class SyncService:
|
||||||
87
leggen/services/transaction_processor.py
Normal file
87
leggen/services/transaction_processor.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionProcessor:
|
||||||
|
"""Handles processing and transformation of raw transaction data"""
|
||||||
|
|
||||||
|
def process_transactions(
|
||||||
|
self,
|
||||||
|
account_id: str,
|
||||||
|
account_info: Dict[str, Any],
|
||||||
|
transaction_data: Dict[str, Any],
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Process raw transaction data into standardized format"""
|
||||||
|
transactions = []
|
||||||
|
|
||||||
|
# Process booked transactions
|
||||||
|
for transaction in transaction_data.get("transactions", {}).get("booked", []):
|
||||||
|
processed = self._process_single_transaction(
|
||||||
|
account_id, account_info, transaction, "booked"
|
||||||
|
)
|
||||||
|
transactions.append(processed)
|
||||||
|
|
||||||
|
# Process pending transactions
|
||||||
|
for transaction in transaction_data.get("transactions", {}).get("pending", []):
|
||||||
|
processed = self._process_single_transaction(
|
||||||
|
account_id, account_info, transaction, "pending"
|
||||||
|
)
|
||||||
|
transactions.append(processed)
|
||||||
|
|
||||||
|
return transactions
|
||||||
|
|
||||||
|
def _process_single_transaction(
|
||||||
|
self,
|
||||||
|
account_id: str,
|
||||||
|
account_info: Dict[str, Any],
|
||||||
|
transaction: Dict[str, Any],
|
||||||
|
status: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Process a single transaction into standardized format"""
|
||||||
|
# Extract dates
|
||||||
|
booked_date = transaction.get("bookingDateTime") or transaction.get(
|
||||||
|
"bookingDate"
|
||||||
|
)
|
||||||
|
value_date = transaction.get("valueDateTime") or transaction.get("valueDate")
|
||||||
|
|
||||||
|
if booked_date and value_date:
|
||||||
|
min_date = min(
|
||||||
|
datetime.fromisoformat(booked_date), datetime.fromisoformat(value_date)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
date_str = booked_date or value_date
|
||||||
|
if not date_str:
|
||||||
|
raise ValueError("No valid date found in transaction")
|
||||||
|
min_date = datetime.fromisoformat(date_str)
|
||||||
|
|
||||||
|
# Extract amount and currency
|
||||||
|
transaction_amount = transaction.get("transactionAmount", {})
|
||||||
|
amount = float(transaction_amount.get("amount", 0))
|
||||||
|
currency = transaction_amount.get("currency", "")
|
||||||
|
|
||||||
|
# Extract description
|
||||||
|
description = transaction.get(
|
||||||
|
"remittanceInformationUnstructured",
|
||||||
|
",".join(transaction.get("remittanceInformationUnstructuredArray", [])),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract transaction IDs - transactionId is now primary, internalTransactionId is reference
|
||||||
|
transaction_id = transaction.get("transactionId")
|
||||||
|
internal_transaction_id = transaction.get("internalTransactionId")
|
||||||
|
|
||||||
|
if not transaction_id:
|
||||||
|
raise ValueError("Transaction missing required transactionId field")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"accountId": account_id,
|
||||||
|
"transactionId": transaction_id,
|
||||||
|
"internalTransactionId": internal_transaction_id,
|
||||||
|
"institutionId": account_info["institution_id"],
|
||||||
|
"iban": account_info.get("iban", "N/A"),
|
||||||
|
"transactionDate": min_date,
|
||||||
|
"description": description,
|
||||||
|
"transactionValue": amount,
|
||||||
|
"transactionCurrency": currency,
|
||||||
|
"transactionStatus": status,
|
||||||
|
"rawTransaction": transaction,
|
||||||
|
}
|
||||||
@@ -1,18 +1,189 @@
|
|||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
import tomllib
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
import tomli_w
|
||||||
|
from loguru import logger
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from leggen.models.config import Config as ConfigModel
|
||||||
|
from leggen.utils.paths import path_manager
|
||||||
from leggen.utils.text import error
|
from leggen.utils.text import error
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
_instance = None
|
||||||
|
_config = None
|
||||||
|
_config_model = None
|
||||||
|
_config_path = None
|
||||||
|
|
||||||
|
def __new__(cls):
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super().__new__(cls)
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def load_config(self, config_path: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
if self._config is not None:
|
||||||
|
return self._config
|
||||||
|
|
||||||
|
if config_path is None:
|
||||||
|
config_path = os.environ.get("LEGGEN_CONFIG_FILE")
|
||||||
|
if not config_path:
|
||||||
|
config_path = str(path_manager.get_config_file_path())
|
||||||
|
|
||||||
|
self._config_path = config_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(config_path, "rb") as f:
|
||||||
|
raw_config = tomllib.load(f)
|
||||||
|
logger.info(f"Configuration loaded from {config_path}")
|
||||||
|
|
||||||
|
# Validate configuration using Pydantic
|
||||||
|
try:
|
||||||
|
self._config_model = ConfigModel(**raw_config)
|
||||||
|
self._config = self._config_model.dict(by_alias=True, exclude_none=True)
|
||||||
|
logger.info("Configuration validation successful")
|
||||||
|
except ValidationError as e:
|
||||||
|
logger.error(f"Configuration validation failed: {e}")
|
||||||
|
raise ValueError(f"Invalid configuration: {e}") from e
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.error(f"Configuration file not found: {config_path}")
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading configuration: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
return self._config
|
||||||
|
|
||||||
|
def save_config(
|
||||||
|
self,
|
||||||
|
config_data: Optional[Dict[str, Any]] = None,
|
||||||
|
config_path: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Save configuration to TOML file"""
|
||||||
|
if config_data is None:
|
||||||
|
config_data = self._config
|
||||||
|
|
||||||
|
if config_path is None:
|
||||||
|
config_path = self._config_path or os.environ.get("LEGGEN_CONFIG_FILE")
|
||||||
|
if not config_path:
|
||||||
|
config_path = str(path_manager.get_config_file_path())
|
||||||
|
|
||||||
|
if config_path is None:
|
||||||
|
raise ValueError("No config path specified")
|
||||||
|
if config_data is None:
|
||||||
|
raise ValueError("No config data to save")
|
||||||
|
|
||||||
|
# Validate the configuration before saving
|
||||||
|
try:
|
||||||
|
validated_model = ConfigModel(**config_data)
|
||||||
|
validated_config = validated_model.dict(by_alias=True, exclude_none=True)
|
||||||
|
except ValidationError as e:
|
||||||
|
logger.error(f"Configuration validation failed before save: {e}")
|
||||||
|
raise ValueError(f"Invalid configuration: {e}") from e
|
||||||
|
|
||||||
|
# Ensure directory exists
|
||||||
|
Path(config_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(config_path, "wb") as f:
|
||||||
|
tomli_w.dump(validated_config, f)
|
||||||
|
|
||||||
|
# Update in-memory config
|
||||||
|
self._config = validated_config
|
||||||
|
self._config_model = validated_model
|
||||||
|
self._config_path = config_path
|
||||||
|
logger.info(f"Configuration saved to {config_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error saving configuration: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def update_config(self, section: str, key: str, value: Any) -> None:
|
||||||
|
"""Update a specific configuration value"""
|
||||||
|
if self._config is None:
|
||||||
|
self.load_config()
|
||||||
|
|
||||||
|
if self._config is None:
|
||||||
|
raise RuntimeError("Failed to load config")
|
||||||
|
|
||||||
|
if section not in self._config:
|
||||||
|
self._config[section] = {}
|
||||||
|
|
||||||
|
self._config[section][key] = value
|
||||||
|
self.save_config()
|
||||||
|
|
||||||
|
def update_section(self, section: str, data: Dict[str, Any]) -> None:
|
||||||
|
"""Update an entire configuration section"""
|
||||||
|
if self._config is None:
|
||||||
|
self.load_config()
|
||||||
|
|
||||||
|
if self._config is None:
|
||||||
|
raise RuntimeError("Failed to load config")
|
||||||
|
|
||||||
|
self._config[section] = data
|
||||||
|
self.save_config()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def config(self) -> Dict[str, Any]:
|
||||||
|
if self._config is None:
|
||||||
|
self.load_config()
|
||||||
|
if self._config is None:
|
||||||
|
raise RuntimeError("Failed to load config")
|
||||||
|
return self._config
|
||||||
|
|
||||||
|
@property
|
||||||
|
def gocardless_config(self) -> Dict[str, str]:
|
||||||
|
return self.config.get("gocardless", {})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def database_config(self) -> Dict[str, Any]:
|
||||||
|
return self.config.get("database", {})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def notifications_config(self) -> Dict[str, Any]:
|
||||||
|
return self.config.get("notifications", {})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def filters_config(self) -> Dict[str, Any]:
|
||||||
|
return self.config.get("filters", {})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def scheduler_config(self) -> Dict[str, Any]:
|
||||||
|
"""Get scheduler configuration with defaults"""
|
||||||
|
default_schedule = {
|
||||||
|
"sync": {
|
||||||
|
"enabled": True,
|
||||||
|
"hour": 3,
|
||||||
|
"minute": 0,
|
||||||
|
"cron": None, # Optional custom cron expression
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self.config.get("scheduler", default_schedule)
|
||||||
|
|
||||||
|
|
||||||
def load_config(ctx: click.Context, _, filename):
|
def load_config(ctx: click.Context, _, filename):
|
||||||
try:
|
try:
|
||||||
with click.open_file(str(filename), "rb") as f:
|
with click.open_file(str(filename), "rb") as f:
|
||||||
# TODO: Implement configuration file validation (use pydantic?)
|
raw_config = tomllib.load(f)
|
||||||
ctx.obj = tomllib.load(f)
|
|
||||||
|
# Validate configuration using Pydantic
|
||||||
|
try:
|
||||||
|
validated_model = ConfigModel(**raw_config)
|
||||||
|
ctx.obj = validated_model.dict(by_alias=True, exclude_none=True)
|
||||||
|
except ValidationError as e:
|
||||||
|
error(f"Configuration validation failed: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
error(
|
error(
|
||||||
"Configuration file not found. Provide a valid configuration file path with leggen --config <path> or LEGGEN_CONFIG=<path> environment variable."
|
"Configuration file not found. Provide a valid configuration file path with leggen --config <path> or LEGGEN_CONFIG=<path> environment variable."
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# Global singleton instance
|
||||||
|
config = Config()
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
|
|
||||||
import click
|
|
||||||
|
|
||||||
import leggen.database.sqlite as sqlite_engine
|
|
||||||
from leggen.utils.text import info, warning
|
|
||||||
|
|
||||||
|
|
||||||
def persist_balance(ctx: click.Context, account: str, balance: dict) -> None:
|
|
||||||
sqlite = ctx.obj.get("database", {}).get("sqlite", True)
|
|
||||||
|
|
||||||
if not sqlite:
|
|
||||||
warning("SQLite database is disabled, skipping balance saving")
|
|
||||||
return
|
|
||||||
|
|
||||||
info(f"[{account}] Fetched balances, saving to SQLite")
|
|
||||||
sqlite_engine.persist_balances(ctx, balance)
|
|
||||||
|
|
||||||
|
|
||||||
def persist_transactions(ctx: click.Context, account: str, transactions: list) -> list:
|
|
||||||
sqlite = ctx.obj.get("database", {}).get("sqlite", True)
|
|
||||||
|
|
||||||
if not sqlite:
|
|
||||||
warning("SQLite database is disabled, skipping transaction saving")
|
|
||||||
# WARNING: This will return the transactions list as is, without saving it to any database
|
|
||||||
# Possible duplicate notifications will be sent if the filters are enabled
|
|
||||||
return transactions
|
|
||||||
|
|
||||||
info(f"[{account}] Fetched {len(transactions)} transactions, saving to SQLite")
|
|
||||||
return sqlite_engine.persist_transactions(ctx, account, transactions)
|
|
||||||
|
|
||||||
|
|
||||||
def save_transactions(ctx: click.Context, account: str) -> list:
|
|
||||||
import requests
|
|
||||||
|
|
||||||
api_url = ctx.obj.get("api_url", "http://localhost:8000")
|
|
||||||
|
|
||||||
info(f"[{account}] Getting account details")
|
|
||||||
res = requests.get(f"{api_url}/accounts/{account}")
|
|
||||||
res.raise_for_status()
|
|
||||||
account_info = res.json()
|
|
||||||
|
|
||||||
info(f"[{account}] Getting transactions")
|
|
||||||
transactions = []
|
|
||||||
|
|
||||||
res = requests.get(f"{api_url}/accounts/{account}/transactions/")
|
|
||||||
res.raise_for_status()
|
|
||||||
account_transactions = res.json().get("transactions", [])
|
|
||||||
|
|
||||||
for transaction in account_transactions.get("booked", []):
|
|
||||||
booked_date = transaction.get("bookingDateTime") or transaction.get(
|
|
||||||
"bookingDate"
|
|
||||||
)
|
|
||||||
value_date = transaction.get("valueDateTime") or transaction.get("valueDate")
|
|
||||||
if booked_date and value_date:
|
|
||||||
min_date = min(
|
|
||||||
datetime.fromisoformat(booked_date), datetime.fromisoformat(value_date)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
min_date = datetime.fromisoformat(booked_date or value_date)
|
|
||||||
|
|
||||||
transactionValue = float(
|
|
||||||
transaction.get("transactionAmount", {}).get("amount", 0)
|
|
||||||
)
|
|
||||||
currency = transaction.get("transactionAmount", {}).get("currency", "")
|
|
||||||
|
|
||||||
description = transaction.get(
|
|
||||||
"remittanceInformationUnstructured",
|
|
||||||
",".join(transaction.get("remittanceInformationUnstructuredArray", [])),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Extract transaction ID, using transactionId as fallback when internalTransactionId is missing
|
|
||||||
transaction_id = transaction.get("internalTransactionId") or transaction.get(
|
|
||||||
"transactionId"
|
|
||||||
)
|
|
||||||
|
|
||||||
t = {
|
|
||||||
"internalTransactionId": transaction_id,
|
|
||||||
"institutionId": account_info["institution_id"],
|
|
||||||
"iban": account_info.get("iban", "N/A"),
|
|
||||||
"transactionDate": min_date,
|
|
||||||
"description": description,
|
|
||||||
"transactionValue": transactionValue,
|
|
||||||
"transactionCurrency": currency,
|
|
||||||
"transactionStatus": "booked",
|
|
||||||
"accountId": account,
|
|
||||||
"rawTransaction": transaction,
|
|
||||||
}
|
|
||||||
transactions.append(t)
|
|
||||||
|
|
||||||
for transaction in account_transactions.get("pending", []):
|
|
||||||
booked_date = transaction.get("bookingDateTime") or transaction.get(
|
|
||||||
"bookingDate"
|
|
||||||
)
|
|
||||||
value_date = transaction.get("valueDateTime") or transaction.get("valueDate")
|
|
||||||
if booked_date and value_date:
|
|
||||||
min_date = min(
|
|
||||||
datetime.fromisoformat(booked_date), datetime.fromisoformat(value_date)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
min_date = datetime.fromisoformat(booked_date or value_date)
|
|
||||||
|
|
||||||
transactionValue = float(
|
|
||||||
transaction.get("transactionAmount", {}).get("amount", 0)
|
|
||||||
)
|
|
||||||
currency = transaction.get("transactionAmount", {}).get("currency", "")
|
|
||||||
|
|
||||||
description = transaction.get(
|
|
||||||
"remittanceInformationUnstructured",
|
|
||||||
",".join(transaction.get("remittanceInformationUnstructuredArray", [])),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Extract transaction ID, using transactionId as fallback when internalTransactionId is missing
|
|
||||||
transaction_id = transaction.get("internalTransactionId") or transaction.get(
|
|
||||||
"transactionId"
|
|
||||||
)
|
|
||||||
|
|
||||||
t = {
|
|
||||||
"internalTransactionId": transaction_id,
|
|
||||||
"institutionId": account_info["institution_id"],
|
|
||||||
"iban": account_info.get("iban", "N/A"),
|
|
||||||
"transactionDate": min_date,
|
|
||||||
"description": description,
|
|
||||||
"transactionValue": transactionValue,
|
|
||||||
"transactionCurrency": currency,
|
|
||||||
"transactionStatus": "pending",
|
|
||||||
"accountId": account,
|
|
||||||
"rawTransaction": transaction,
|
|
||||||
}
|
|
||||||
transactions.append(t)
|
|
||||||
|
|
||||||
return persist_transactions(ctx, account, transactions)
|
|
||||||
78
leggen/utils/paths.py
Normal file
78
leggen/utils/paths.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""Centralized path management for Leggen."""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class PathManager:
|
||||||
|
"""Manages configurable paths for config and database files."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._config_dir: Optional[Path] = None
|
||||||
|
self._database_path: Optional[Path] = None
|
||||||
|
|
||||||
|
def get_config_dir(self) -> Path:
|
||||||
|
"""Get the configuration directory."""
|
||||||
|
if self._config_dir is not None:
|
||||||
|
return self._config_dir
|
||||||
|
|
||||||
|
# Check environment variable first
|
||||||
|
config_dir = os.environ.get("LEGGEN_CONFIG_DIR")
|
||||||
|
if config_dir:
|
||||||
|
return Path(config_dir)
|
||||||
|
|
||||||
|
# Default to ~/.config/leggen
|
||||||
|
return Path.home() / ".config" / "leggen"
|
||||||
|
|
||||||
|
def set_config_dir(self, path: Path) -> None:
|
||||||
|
"""Set the configuration directory."""
|
||||||
|
self._config_dir = Path(path)
|
||||||
|
|
||||||
|
def get_config_file_path(self) -> Path:
|
||||||
|
"""Get the configuration file path."""
|
||||||
|
return self.get_config_dir() / "config.toml"
|
||||||
|
|
||||||
|
def get_database_path(self) -> Path:
|
||||||
|
"""Get the database file path and ensure the directory exists."""
|
||||||
|
if self._database_path is not None:
|
||||||
|
db_path = self._database_path
|
||||||
|
else:
|
||||||
|
# Check environment variable first
|
||||||
|
database_path = os.environ.get("LEGGEN_DATABASE_PATH")
|
||||||
|
if database_path:
|
||||||
|
db_path = Path(database_path)
|
||||||
|
else:
|
||||||
|
# Default to config_dir/leggen.db
|
||||||
|
db_path = self.get_config_dir() / "leggen.db"
|
||||||
|
|
||||||
|
# Try to ensure the directory exists, but handle permission errors gracefully
|
||||||
|
with contextlib.suppress(PermissionError, OSError):
|
||||||
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
return db_path
|
||||||
|
|
||||||
|
def set_database_path(self, path: Path) -> None:
|
||||||
|
"""Set the database file path."""
|
||||||
|
self._database_path = Path(path)
|
||||||
|
|
||||||
|
def get_auth_file_path(self) -> Path:
|
||||||
|
"""Get the authentication file path."""
|
||||||
|
return self.get_config_dir() / "auth.json"
|
||||||
|
|
||||||
|
def ensure_config_dir_exists(self) -> None:
|
||||||
|
"""Ensure the configuration directory exists."""
|
||||||
|
self.get_config_dir().mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def ensure_database_dir_exists(self) -> None:
|
||||||
|
"""Ensure the database directory exists.
|
||||||
|
|
||||||
|
Note: get_database_path() now automatically ensures the directory exists,
|
||||||
|
so this method is mainly for explicit directory creation in tests.
|
||||||
|
"""
|
||||||
|
self.get_database_path().parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# Global instance for the application
|
||||||
|
path_manager = PathManager()
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
import os
|
|
||||||
import tomllib
|
|
||||||
import tomli_w
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Dict, Any, Optional
|
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
_instance = None
|
|
||||||
_config = None
|
|
||||||
_config_path = None
|
|
||||||
|
|
||||||
def __new__(cls):
|
|
||||||
if cls._instance is None:
|
|
||||||
cls._instance = super().__new__(cls)
|
|
||||||
return cls._instance
|
|
||||||
|
|
||||||
def load_config(self, config_path: Optional[str] = None) -> Dict[str, Any]:
|
|
||||||
if self._config is not None:
|
|
||||||
return self._config
|
|
||||||
|
|
||||||
if config_path is None:
|
|
||||||
config_path = os.environ.get(
|
|
||||||
"LEGGEN_CONFIG_FILE",
|
|
||||||
str(Path.home() / ".config" / "leggen" / "config.toml"),
|
|
||||||
)
|
|
||||||
|
|
||||||
self._config_path = config_path
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(config_path, "rb") as f:
|
|
||||||
self._config = tomllib.load(f)
|
|
||||||
logger.info(f"Configuration loaded from {config_path}")
|
|
||||||
except FileNotFoundError:
|
|
||||||
logger.error(f"Configuration file not found: {config_path}")
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error loading configuration: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
return self._config
|
|
||||||
|
|
||||||
def save_config(
|
|
||||||
self,
|
|
||||||
config_data: Optional[Dict[str, Any]] = None,
|
|
||||||
config_path: Optional[str] = None,
|
|
||||||
) -> None:
|
|
||||||
"""Save configuration to TOML file"""
|
|
||||||
if config_data is None:
|
|
||||||
config_data = self._config
|
|
||||||
|
|
||||||
if config_path is None:
|
|
||||||
config_path = self._config_path or os.environ.get(
|
|
||||||
"LEGGEN_CONFIG_FILE",
|
|
||||||
str(Path.home() / ".config" / "leggen" / "config.toml"),
|
|
||||||
)
|
|
||||||
|
|
||||||
if config_path is None:
|
|
||||||
raise ValueError("No config path specified")
|
|
||||||
if config_data is None:
|
|
||||||
raise ValueError("No config data to save")
|
|
||||||
|
|
||||||
# Ensure directory exists
|
|
||||||
Path(config_path).parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(config_path, "wb") as f:
|
|
||||||
tomli_w.dump(config_data, f)
|
|
||||||
|
|
||||||
# Update in-memory config
|
|
||||||
self._config = config_data
|
|
||||||
self._config_path = config_path
|
|
||||||
logger.info(f"Configuration saved to {config_path}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error saving configuration: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def update_config(self, section: str, key: str, value: Any) -> None:
|
|
||||||
"""Update a specific configuration value"""
|
|
||||||
if self._config is None:
|
|
||||||
self.load_config()
|
|
||||||
|
|
||||||
if self._config is None:
|
|
||||||
raise RuntimeError("Failed to load config")
|
|
||||||
|
|
||||||
if section not in self._config:
|
|
||||||
self._config[section] = {}
|
|
||||||
|
|
||||||
self._config[section][key] = value
|
|
||||||
self.save_config()
|
|
||||||
|
|
||||||
def update_section(self, section: str, data: Dict[str, Any]) -> None:
|
|
||||||
"""Update an entire configuration section"""
|
|
||||||
if self._config is None:
|
|
||||||
self.load_config()
|
|
||||||
|
|
||||||
if self._config is None:
|
|
||||||
raise RuntimeError("Failed to load config")
|
|
||||||
|
|
||||||
self._config[section] = data
|
|
||||||
self.save_config()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def config(self) -> Dict[str, Any]:
|
|
||||||
if self._config is None:
|
|
||||||
self.load_config()
|
|
||||||
if self._config is None:
|
|
||||||
raise RuntimeError("Failed to load config")
|
|
||||||
return self._config
|
|
||||||
|
|
||||||
@property
|
|
||||||
def gocardless_config(self) -> Dict[str, str]:
|
|
||||||
return self.config.get("gocardless", {})
|
|
||||||
|
|
||||||
@property
|
|
||||||
def database_config(self) -> Dict[str, Any]:
|
|
||||||
return self.config.get("database", {})
|
|
||||||
|
|
||||||
@property
|
|
||||||
def notifications_config(self) -> Dict[str, Any]:
|
|
||||||
return self.config.get("notifications", {})
|
|
||||||
|
|
||||||
@property
|
|
||||||
def filters_config(self) -> Dict[str, Any]:
|
|
||||||
return self.config.get("filters", {})
|
|
||||||
|
|
||||||
@property
|
|
||||||
def scheduler_config(self) -> Dict[str, Any]:
|
|
||||||
"""Get scheduler configuration with defaults"""
|
|
||||||
default_schedule = {
|
|
||||||
"sync": {
|
|
||||||
"enabled": True,
|
|
||||||
"hour": 3,
|
|
||||||
"minute": 0,
|
|
||||||
"cron": None, # Optional custom cron expression
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return self.config.get("scheduler", default_schedule)
|
|
||||||
|
|
||||||
|
|
||||||
config = Config()
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
REQUISITION_STATUS = {
|
|
||||||
"CR": "CREATED",
|
|
||||||
"GC": "GIVING_CONSENT",
|
|
||||||
"UA": "UNDERGOING_AUTHENTICATION",
|
|
||||||
"RJ": "REJECTED",
|
|
||||||
"SA": "SELECTING_ACCOUNTS",
|
|
||||||
"GA": "GRANTING_ACCESS",
|
|
||||||
"LN": "LINKED",
|
|
||||||
"EX": "EXPIRED",
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "leggen"
|
name = "leggen"
|
||||||
version = "2025.9.5"
|
version = "2025.9.10"
|
||||||
description = "An Open Banking CLI"
|
description = "An Open Banking CLI"
|
||||||
authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }]
|
authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }]
|
||||||
requires-python = "~=3.13.0"
|
requires-python = "~=3.13.0"
|
||||||
@@ -34,6 +34,7 @@ dependencies = [
|
|||||||
"apscheduler>=3.10.0,<4",
|
"apscheduler>=3.10.0,<4",
|
||||||
"tomli-w>=1.0.0,<2",
|
"tomli-w>=1.0.0,<2",
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
|
"pydantic>=2.0.0,<3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
@@ -41,7 +42,6 @@ Repository = "https://github.com/elisiariocouto/leggen"
|
|||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
leggen = "leggen.main:cli"
|
leggen = "leggen.main:cli"
|
||||||
leggend = "leggend.main:main"
|
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
@@ -58,10 +58,10 @@ dev = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[tool.hatch.build.targets.sdist]
|
[tool.hatch.build.targets.sdist]
|
||||||
include = ["leggen", "leggend"]
|
include = ["leggen"]
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
include = ["leggen", "leggend"]
|
include = ["leggen"]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
@@ -69,7 +69,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
lint.ignore = ["E501", "B008", "B006"]
|
lint.ignore = ["E501", "B008", "B006"]
|
||||||
lint.extend-select = ["B", "C4", "PIE", "T20", "SIM", "TCH"]
|
lint.extend-select = ["B", "C4", "I", "PIE", "T20", "SIM", "TCH"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
|||||||
546
scripts/generate_sample_db.py
Executable file
546
scripts/generate_sample_db.py
Executable file
@@ -0,0 +1,546 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Sample database generator for Leggen testing and development."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
# Add the project root to the Python path
|
||||||
|
project_root = Path(__file__).parent.parent
|
||||||
|
sys.path.insert(0, str(project_root))
|
||||||
|
|
||||||
|
# Import after path setup - this is necessary for the script to work
|
||||||
|
from leggen.utils.paths import path_manager # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class SampleDataGenerator:
|
||||||
|
"""Generates realistic sample data for testing Leggen."""
|
||||||
|
|
||||||
|
def __init__(self, db_path: Path):
|
||||||
|
self.db_path = db_path
|
||||||
|
self.institutions = [
|
||||||
|
{
|
||||||
|
"id": "REVOLUT_REVOLT21",
|
||||||
|
"name": "Revolut",
|
||||||
|
"bic": "REVOLT21",
|
||||||
|
"country": "LT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BANCOBPI_BBPIPTPL",
|
||||||
|
"name": "Banco BPI",
|
||||||
|
"bic": "BBPIPTPL",
|
||||||
|
"country": "PT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "MONZO_MONZGB2L",
|
||||||
|
"name": "Monzo Bank",
|
||||||
|
"bic": "MONZGB2L",
|
||||||
|
"country": "GB",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "NUBANK_NUPBBR25",
|
||||||
|
"name": "Nu Pagamentos",
|
||||||
|
"bic": "NUPBBR25",
|
||||||
|
"country": "BR",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
self.transaction_types = [
|
||||||
|
{
|
||||||
|
"description": "Grocery Store",
|
||||||
|
"amount_range": (-150, -20),
|
||||||
|
"frequency": 0.3,
|
||||||
|
},
|
||||||
|
{"description": "Coffee Shop", "amount_range": (-15, -3), "frequency": 0.2},
|
||||||
|
{
|
||||||
|
"description": "Gas Station",
|
||||||
|
"amount_range": (-80, -30),
|
||||||
|
"frequency": 0.1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Online Shopping",
|
||||||
|
"amount_range": (-200, -25),
|
||||||
|
"frequency": 0.15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Restaurant",
|
||||||
|
"amount_range": (-60, -15),
|
||||||
|
"frequency": 0.15,
|
||||||
|
},
|
||||||
|
{"description": "Salary", "amount_range": (2500, 5000), "frequency": 0.02},
|
||||||
|
{
|
||||||
|
"description": "ATM Withdrawal",
|
||||||
|
"amount_range": (-200, -20),
|
||||||
|
"frequency": 0.05,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Transfer to Savings",
|
||||||
|
"amount_range": (-1000, -100),
|
||||||
|
"frequency": 0.03,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
def ensure_database_dir(self):
|
||||||
|
"""Ensure database directory exists."""
|
||||||
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def create_tables(self):
|
||||||
|
"""Create database tables."""
|
||||||
|
conn = sqlite3.connect(str(self.db_path))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Create accounts table
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS accounts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
institution_id TEXT,
|
||||||
|
status TEXT,
|
||||||
|
iban TEXT,
|
||||||
|
name TEXT,
|
||||||
|
currency TEXT,
|
||||||
|
created DATETIME,
|
||||||
|
last_accessed DATETIME,
|
||||||
|
last_updated DATETIME
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Create transactions table with composite primary key
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS transactions (
|
||||||
|
accountId TEXT NOT NULL,
|
||||||
|
transactionId TEXT NOT NULL,
|
||||||
|
internalTransactionId TEXT,
|
||||||
|
institutionId TEXT,
|
||||||
|
iban TEXT,
|
||||||
|
transactionDate DATETIME,
|
||||||
|
description TEXT,
|
||||||
|
transactionValue REAL,
|
||||||
|
transactionCurrency TEXT,
|
||||||
|
transactionStatus TEXT,
|
||||||
|
rawTransaction JSON,
|
||||||
|
PRIMARY KEY (accountId, transactionId)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Create balances table
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS balances (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
account_id TEXT,
|
||||||
|
bank TEXT,
|
||||||
|
status TEXT,
|
||||||
|
iban TEXT,
|
||||||
|
amount REAL,
|
||||||
|
currency TEXT,
|
||||||
|
type TEXT,
|
||||||
|
timestamp DATETIME
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Create indexes
|
||||||
|
cursor.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_transactions_internal_id ON transactions(internalTransactionId)"
|
||||||
|
)
|
||||||
|
cursor.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(transactionDate)"
|
||||||
|
)
|
||||||
|
cursor.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_transactions_account_date ON transactions(accountId, transactionDate)"
|
||||||
|
)
|
||||||
|
cursor.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_transactions_amount ON transactions(transactionValue)"
|
||||||
|
)
|
||||||
|
cursor.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_balances_account_id ON balances(account_id)"
|
||||||
|
)
|
||||||
|
cursor.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_balances_timestamp ON balances(timestamp)"
|
||||||
|
)
|
||||||
|
cursor.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_balances_account_type_timestamp ON balances(account_id, type, timestamp)"
|
||||||
|
)
|
||||||
|
cursor.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_accounts_institution_id ON accounts(institution_id)"
|
||||||
|
)
|
||||||
|
cursor.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_accounts_status ON accounts(status)"
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def generate_iban(self, country_code: str) -> str:
|
||||||
|
"""Generate a realistic IBAN for the given country."""
|
||||||
|
ibans = {
|
||||||
|
"LT": lambda: f"LT{random.randint(10, 99)}{random.randint(10000, 99999)}{random.randint(10000000, 99999999)}",
|
||||||
|
"PT": lambda: f"PT{random.randint(10, 99)}{random.randint(1000, 9999)}{random.randint(1000, 9999)}{random.randint(10000000000, 99999999999)}",
|
||||||
|
"GB": lambda: f"GB{random.randint(10, 99)}MONZ{random.randint(100000, 999999)}{random.randint(100000, 999999)}",
|
||||||
|
"BR": lambda: f"BR{random.randint(10, 99)}{random.randint(10000000, 99999999)}{random.randint(1000, 9999)}{random.randint(10000000, 99999999)}",
|
||||||
|
}
|
||||||
|
return ibans.get(
|
||||||
|
country_code,
|
||||||
|
lambda: f"{country_code}{random.randint(1000000000000000, 9999999999999999)}",
|
||||||
|
)()
|
||||||
|
|
||||||
|
def generate_accounts(self, num_accounts: int = 3) -> List[Dict[str, Any]]:
|
||||||
|
"""Generate sample accounts."""
|
||||||
|
accounts = []
|
||||||
|
base_date = datetime.now() - timedelta(days=90)
|
||||||
|
|
||||||
|
for i in range(num_accounts):
|
||||||
|
institution = random.choice(self.institutions)
|
||||||
|
account_id = f"account-{i + 1:03d}-{random.randint(1000, 9999)}"
|
||||||
|
|
||||||
|
account = {
|
||||||
|
"id": account_id,
|
||||||
|
"institution_id": institution["id"],
|
||||||
|
"status": "READY",
|
||||||
|
"iban": self.generate_iban(institution["country"]),
|
||||||
|
"name": f"Personal Account {i + 1}",
|
||||||
|
"currency": "EUR",
|
||||||
|
"created": (
|
||||||
|
base_date + timedelta(days=random.randint(0, 30))
|
||||||
|
).isoformat(),
|
||||||
|
"last_accessed": (
|
||||||
|
datetime.now() - timedelta(hours=random.randint(1, 48))
|
||||||
|
).isoformat(),
|
||||||
|
"last_updated": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
accounts.append(account)
|
||||||
|
|
||||||
|
return accounts
|
||||||
|
|
||||||
|
def generate_transactions(
|
||||||
|
self, accounts: List[Dict[str, Any]], num_transactions_per_account: int = 50
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Generate sample transactions for accounts."""
|
||||||
|
transactions = []
|
||||||
|
base_date = datetime.now() - timedelta(days=60)
|
||||||
|
|
||||||
|
for account in accounts:
|
||||||
|
account_transactions = []
|
||||||
|
current_balance = random.uniform(500, 3000)
|
||||||
|
|
||||||
|
for i in range(num_transactions_per_account):
|
||||||
|
# Choose transaction type based on frequency weights
|
||||||
|
transaction_type = random.choices(
|
||||||
|
self.transaction_types,
|
||||||
|
weights=[t["frequency"] for t in self.transaction_types],
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
# Generate transaction amount
|
||||||
|
min_amount, max_amount = transaction_type["amount_range"]
|
||||||
|
amount = round(random.uniform(min_amount, max_amount), 2)
|
||||||
|
|
||||||
|
# Generate transaction date (more recent transactions are more likely)
|
||||||
|
days_ago = random.choices(
|
||||||
|
range(60), weights=[1.5 ** (60 - d) for d in range(60)]
|
||||||
|
)[0]
|
||||||
|
transaction_date = base_date + timedelta(
|
||||||
|
days=days_ago,
|
||||||
|
hours=random.randint(6, 22),
|
||||||
|
minutes=random.randint(0, 59),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate transaction IDs
|
||||||
|
transaction_id = f"bank-txn-{account['id']}-{i + 1:04d}"
|
||||||
|
internal_transaction_id = f"int-txn-{random.randint(100000, 999999)}"
|
||||||
|
|
||||||
|
# Create realistic descriptions
|
||||||
|
descriptions = {
|
||||||
|
"Grocery Store": [
|
||||||
|
"TESCO",
|
||||||
|
"SAINSBURY'S",
|
||||||
|
"LIDL",
|
||||||
|
"ALDI",
|
||||||
|
"WALMART",
|
||||||
|
"CARREFOUR",
|
||||||
|
],
|
||||||
|
"Coffee Shop": [
|
||||||
|
"STARBUCKS",
|
||||||
|
"COSTA COFFEE",
|
||||||
|
"PRET A MANGER",
|
||||||
|
"LOCAL CAFE",
|
||||||
|
],
|
||||||
|
"Gas Station": ["BP", "SHELL", "ESSO", "GALP", "PETROBRAS"],
|
||||||
|
"Online Shopping": ["AMAZON", "EBAY", "ZALANDO", "ASOS", "APPLE"],
|
||||||
|
"Restaurant": [
|
||||||
|
"PIZZA HUT",
|
||||||
|
"MCDONALD'S",
|
||||||
|
"BURGER KING",
|
||||||
|
"LOCAL RESTAURANT",
|
||||||
|
],
|
||||||
|
"Salary": ["MONTHLY SALARY", "PAYROLL DEPOSIT", "SALARY PAYMENT"],
|
||||||
|
"ATM Withdrawal": ["ATM WITHDRAWAL", "CASH WITHDRAWAL"],
|
||||||
|
"Transfer to Savings": ["SAVINGS TRANSFER", "INVESTMENT TRANSFER"],
|
||||||
|
}
|
||||||
|
|
||||||
|
specific_descriptions = descriptions.get(
|
||||||
|
transaction_type["description"], [transaction_type["description"]]
|
||||||
|
)
|
||||||
|
description = random.choice(specific_descriptions)
|
||||||
|
|
||||||
|
# Create raw transaction (simplified GoCardless format)
|
||||||
|
raw_transaction = {
|
||||||
|
"transactionId": transaction_id,
|
||||||
|
"bookingDate": transaction_date.strftime("%Y-%m-%d"),
|
||||||
|
"valueDate": transaction_date.strftime("%Y-%m-%d"),
|
||||||
|
"transactionAmount": {
|
||||||
|
"amount": str(amount),
|
||||||
|
"currency": account["currency"],
|
||||||
|
},
|
||||||
|
"remittanceInformationUnstructured": description,
|
||||||
|
"bankTransactionCode": "PMNT" if amount < 0 else "RCDT",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Determine status (most are booked, some recent ones might be pending)
|
||||||
|
status = (
|
||||||
|
"pending" if days_ago < 2 and random.random() < 0.1 else "booked"
|
||||||
|
)
|
||||||
|
|
||||||
|
transaction = {
|
||||||
|
"accountId": account["id"],
|
||||||
|
"transactionId": transaction_id,
|
||||||
|
"internalTransactionId": internal_transaction_id,
|
||||||
|
"institutionId": account["institution_id"],
|
||||||
|
"iban": account["iban"],
|
||||||
|
"transactionDate": transaction_date.isoformat(),
|
||||||
|
"description": description,
|
||||||
|
"transactionValue": amount,
|
||||||
|
"transactionCurrency": account["currency"],
|
||||||
|
"transactionStatus": status,
|
||||||
|
"rawTransaction": raw_transaction,
|
||||||
|
}
|
||||||
|
|
||||||
|
account_transactions.append(transaction)
|
||||||
|
current_balance += amount
|
||||||
|
|
||||||
|
# Sort transactions by date for realistic ordering
|
||||||
|
account_transactions.sort(key=lambda x: x["transactionDate"])
|
||||||
|
transactions.extend(account_transactions)
|
||||||
|
|
||||||
|
return transactions
|
||||||
|
|
||||||
|
def generate_balances(self, accounts: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||||
|
"""Generate sample balances for accounts."""
|
||||||
|
balances = []
|
||||||
|
|
||||||
|
for account in accounts:
|
||||||
|
# Calculate balance from transactions (simplified)
|
||||||
|
base_balance = random.uniform(500, 2000)
|
||||||
|
|
||||||
|
balance_types = ["interimAvailable", "closingBooked", "authorised"]
|
||||||
|
|
||||||
|
for balance_type in balance_types:
|
||||||
|
# Add some variation to balance types
|
||||||
|
variation = (
|
||||||
|
random.uniform(-50, 50) if balance_type != "interimAvailable" else 0
|
||||||
|
)
|
||||||
|
balance_amount = base_balance + variation
|
||||||
|
|
||||||
|
balance = {
|
||||||
|
"account_id": account["id"],
|
||||||
|
"bank": account["institution_id"],
|
||||||
|
"status": account["status"],
|
||||||
|
"iban": account["iban"],
|
||||||
|
"amount": round(balance_amount, 2),
|
||||||
|
"currency": account["currency"],
|
||||||
|
"type": balance_type,
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
balances.append(balance)
|
||||||
|
|
||||||
|
return balances
|
||||||
|
|
||||||
|
def insert_data(
|
||||||
|
self,
|
||||||
|
accounts: List[Dict[str, Any]],
|
||||||
|
transactions: List[Dict[str, Any]],
|
||||||
|
balances: List[Dict[str, Any]],
|
||||||
|
):
|
||||||
|
"""Insert generated data into the database."""
|
||||||
|
conn = sqlite3.connect(str(self.db_path))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Insert accounts
|
||||||
|
for account in accounts:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR REPLACE INTO accounts
|
||||||
|
(id, institution_id, status, iban, name, currency, created, last_accessed, last_updated)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
account["id"],
|
||||||
|
account["institution_id"],
|
||||||
|
account["status"],
|
||||||
|
account["iban"],
|
||||||
|
account["name"],
|
||||||
|
account["currency"],
|
||||||
|
account["created"],
|
||||||
|
account["last_accessed"],
|
||||||
|
account["last_updated"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Insert transactions
|
||||||
|
for transaction in transactions:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR REPLACE INTO transactions
|
||||||
|
(accountId, transactionId, internalTransactionId, institutionId, iban,
|
||||||
|
transactionDate, description, transactionValue, transactionCurrency,
|
||||||
|
transactionStatus, rawTransaction)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
transaction["accountId"],
|
||||||
|
transaction["transactionId"],
|
||||||
|
transaction["internalTransactionId"],
|
||||||
|
transaction["institutionId"],
|
||||||
|
transaction["iban"],
|
||||||
|
transaction["transactionDate"],
|
||||||
|
transaction["description"],
|
||||||
|
transaction["transactionValue"],
|
||||||
|
transaction["transactionCurrency"],
|
||||||
|
transaction["transactionStatus"],
|
||||||
|
json.dumps(transaction["rawTransaction"]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Insert balances
|
||||||
|
for balance in balances:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO balances
|
||||||
|
(account_id, bank, status, iban, amount, currency, type, timestamp)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
balance["account_id"],
|
||||||
|
balance["bank"],
|
||||||
|
balance["status"],
|
||||||
|
balance["iban"],
|
||||||
|
balance["amount"],
|
||||||
|
balance["currency"],
|
||||||
|
balance["type"],
|
||||||
|
balance["timestamp"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def generate_sample_database(
|
||||||
|
self, num_accounts: int = 3, num_transactions_per_account: int = 50
|
||||||
|
):
|
||||||
|
"""Generate complete sample database."""
|
||||||
|
click.echo(f"🗄️ Creating sample database at: {self.db_path}")
|
||||||
|
|
||||||
|
self.ensure_database_dir()
|
||||||
|
self.create_tables()
|
||||||
|
|
||||||
|
click.echo(f"👥 Generating {num_accounts} sample accounts...")
|
||||||
|
accounts = self.generate_accounts(num_accounts)
|
||||||
|
|
||||||
|
click.echo(
|
||||||
|
f"💳 Generating {num_transactions_per_account} transactions per account..."
|
||||||
|
)
|
||||||
|
transactions = self.generate_transactions(
|
||||||
|
accounts, num_transactions_per_account
|
||||||
|
)
|
||||||
|
|
||||||
|
click.echo("💰 Generating account balances...")
|
||||||
|
balances = self.generate_balances(accounts)
|
||||||
|
|
||||||
|
click.echo("💾 Inserting data into database...")
|
||||||
|
self.insert_data(accounts, transactions, balances)
|
||||||
|
|
||||||
|
# Print summary
|
||||||
|
click.echo("\n✅ Sample database created successfully!")
|
||||||
|
click.echo("📊 Summary:")
|
||||||
|
click.echo(f" - Accounts: {len(accounts)}")
|
||||||
|
click.echo(f" - Transactions: {len(transactions)}")
|
||||||
|
click.echo(f" - Balances: {len(balances)}")
|
||||||
|
click.echo(f" - Database: {self.db_path}")
|
||||||
|
|
||||||
|
# Show account details
|
||||||
|
click.echo("\n📋 Sample accounts:")
|
||||||
|
for account in accounts:
|
||||||
|
institution_name = next(
|
||||||
|
inst["name"]
|
||||||
|
for inst in self.institutions
|
||||||
|
if inst["id"] == account["institution_id"]
|
||||||
|
)
|
||||||
|
click.echo(f" - {account['id']} ({institution_name}) - {account['iban']}")
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option(
|
||||||
|
"--database",
|
||||||
|
type=click.Path(path_type=Path),
|
||||||
|
help="Path to database file (default: uses LEGGEN_DATABASE_PATH or ~/.config/leggen/leggen-dev.db)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--accounts",
|
||||||
|
type=int,
|
||||||
|
default=3,
|
||||||
|
help="Number of sample accounts to generate (default: 3)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--transactions",
|
||||||
|
type=int,
|
||||||
|
default=50,
|
||||||
|
help="Number of transactions per account (default: 50)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--force",
|
||||||
|
is_flag=True,
|
||||||
|
help="Overwrite existing database without confirmation",
|
||||||
|
)
|
||||||
|
def main(database: Path, accounts: int, transactions: int, force: bool):
|
||||||
|
"""Generate a sample database with realistic financial data for testing Leggen."""
|
||||||
|
|
||||||
|
# Determine database path
|
||||||
|
if database:
|
||||||
|
db_path = database
|
||||||
|
else:
|
||||||
|
# Use development database by default to avoid overwriting production data
|
||||||
|
import os
|
||||||
|
|
||||||
|
env_path = os.environ.get("LEGGEN_DATABASE_PATH")
|
||||||
|
if env_path:
|
||||||
|
db_path = Path(env_path)
|
||||||
|
else:
|
||||||
|
# Default to development database in config directory
|
||||||
|
db_path = path_manager.get_config_dir() / "leggen-dev.db"
|
||||||
|
|
||||||
|
# Check if database exists and ask for confirmation
|
||||||
|
if db_path.exists() and not force:
|
||||||
|
click.echo(f"⚠️ Database already exists: {db_path}")
|
||||||
|
if not click.confirm("Do you want to overwrite it?"):
|
||||||
|
click.echo("Aborted.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Generate the sample database
|
||||||
|
generator = SampleDataGenerator(db_path)
|
||||||
|
generator.generate_sample_database(accounts, transactions)
|
||||||
|
|
||||||
|
# Show usage instructions
|
||||||
|
click.echo("\n🚀 Usage instructions:")
|
||||||
|
click.echo("To use this sample database with leggen commands:")
|
||||||
|
click.echo(f" export LEGGEN_DATABASE_PATH={db_path}")
|
||||||
|
click.echo(" leggen transactions")
|
||||||
|
click.echo("")
|
||||||
|
click.echo("To use this sample database with leggen server:")
|
||||||
|
click.echo(f" leggen server --database {db_path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
"""Pytest configuration and shared fixtures."""
|
"""Pytest configuration and shared fixtures."""
|
||||||
|
|
||||||
import pytest
|
|
||||||
import tempfile
|
|
||||||
import json
|
import json
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from leggend.main import create_app
|
from leggen.commands.server import create_app
|
||||||
from leggend.config import Config
|
from leggen.utils.config import Config
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -86,23 +87,17 @@ def api_client(fastapi_app):
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_db_path(temp_db_path):
|
def mock_db_path(temp_db_path):
|
||||||
"""Mock the database path to use temporary database for testing."""
|
"""Mock the database path to use temporary database for testing."""
|
||||||
from pathlib import Path
|
from leggen.utils.paths import path_manager
|
||||||
|
|
||||||
# Create the expected directory structure
|
# Set the path manager to use the temporary database
|
||||||
temp_home = temp_db_path.parent
|
original_database_path = path_manager._database_path
|
||||||
config_dir = temp_home / ".config" / "leggen"
|
path_manager.set_database_path(temp_db_path)
|
||||||
config_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Create the expected database path
|
try:
|
||||||
expected_db_path = config_dir / "leggen.db"
|
yield temp_db_path
|
||||||
|
finally:
|
||||||
# Mock Path.home to return our temp directory
|
# Restore original path
|
||||||
def mock_home():
|
path_manager._database_path = original_database_path
|
||||||
return temp_home
|
|
||||||
|
|
||||||
# Patch Path.home in the main pathlib module
|
|
||||||
with patch.object(Path, "home", staticmethod(mock_home)):
|
|
||||||
yield expected_db_path
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
139
tests/unit/test_analytics_fix.py
Normal file
139
tests/unit/test_analytics_fix.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"""Tests for analytics fixes to ensure all transactions are used in statistics."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from leggen.commands.server import create_app
|
||||||
|
from leggen.services.database_service import DatabaseService
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnalyticsFix:
|
||||||
|
"""Test analytics fixes for transaction limits"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(self):
|
||||||
|
app = create_app()
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_database_service(self):
|
||||||
|
return Mock(spec=DatabaseService)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_transaction_stats_uses_all_transactions(self, mock_database_service):
|
||||||
|
"""Test that transaction stats endpoint uses all transactions (not limited to 100)"""
|
||||||
|
# Mock data for 600 transactions (simulating the issue)
|
||||||
|
mock_transactions = []
|
||||||
|
for i in range(600):
|
||||||
|
mock_transactions.append(
|
||||||
|
{
|
||||||
|
"transactionId": f"txn-{i}",
|
||||||
|
"transactionDate": (
|
||||||
|
datetime.now() - timedelta(days=i % 365)
|
||||||
|
).isoformat(),
|
||||||
|
"description": f"Transaction {i}",
|
||||||
|
"transactionValue": 10.0 if i % 2 == 0 else -5.0,
|
||||||
|
"transactionCurrency": "EUR",
|
||||||
|
"transactionStatus": "booked",
|
||||||
|
"accountId": f"account-{i % 3}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_database_service.get_transactions_from_db = AsyncMock(
|
||||||
|
return_value=mock_transactions
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test that the endpoint calls get_transactions_from_db with limit=None
|
||||||
|
with patch(
|
||||||
|
"leggen.api.routes.transactions.database_service", mock_database_service
|
||||||
|
):
|
||||||
|
app = create_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
response = client.get("/api/v1/transactions/stats?days=365")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Verify that limit=None was passed to get all transactions
|
||||||
|
mock_database_service.get_transactions_from_db.assert_called_once()
|
||||||
|
call_args = mock_database_service.get_transactions_from_db.call_args
|
||||||
|
assert call_args.kwargs.get("limit") is None, (
|
||||||
|
"Stats endpoint should pass limit=None to get all transactions"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify that the response contains stats for all 600 transactions
|
||||||
|
assert data["success"] is True
|
||||||
|
stats = data["data"]
|
||||||
|
assert stats["total_transactions"] == 600, (
|
||||||
|
"Should process all 600 transactions, not just 100"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify calculations are correct for all transactions
|
||||||
|
expected_income = sum(
|
||||||
|
txn["transactionValue"]
|
||||||
|
for txn in mock_transactions
|
||||||
|
if txn["transactionValue"] > 0
|
||||||
|
)
|
||||||
|
expected_expenses = sum(
|
||||||
|
abs(txn["transactionValue"])
|
||||||
|
for txn in mock_transactions
|
||||||
|
if txn["transactionValue"] < 0
|
||||||
|
)
|
||||||
|
|
||||||
|
assert stats["total_income"] == expected_income
|
||||||
|
assert stats["total_expenses"] == expected_expenses
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_analytics_endpoint_returns_all_transactions(
|
||||||
|
self, mock_database_service
|
||||||
|
):
|
||||||
|
"""Test that the new analytics endpoint returns all transactions without pagination"""
|
||||||
|
# Mock data for 600 transactions
|
||||||
|
mock_transactions = []
|
||||||
|
for i in range(600):
|
||||||
|
mock_transactions.append(
|
||||||
|
{
|
||||||
|
"transactionId": f"txn-{i}",
|
||||||
|
"transactionDate": (
|
||||||
|
datetime.now() - timedelta(days=i % 365)
|
||||||
|
).isoformat(),
|
||||||
|
"description": f"Transaction {i}",
|
||||||
|
"transactionValue": 10.0 if i % 2 == 0 else -5.0,
|
||||||
|
"transactionCurrency": "EUR",
|
||||||
|
"transactionStatus": "booked",
|
||||||
|
"accountId": f"account-{i % 3}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_database_service.get_transactions_from_db = AsyncMock(
|
||||||
|
return_value=mock_transactions
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"leggen.api.routes.transactions.database_service", mock_database_service
|
||||||
|
):
|
||||||
|
app = create_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
response = client.get("/api/v1/transactions/analytics?days=365")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Verify that limit=None was passed to get all transactions
|
||||||
|
mock_database_service.get_transactions_from_db.assert_called_once()
|
||||||
|
call_args = mock_database_service.get_transactions_from_db.call_args
|
||||||
|
assert call_args.kwargs.get("limit") is None, (
|
||||||
|
"Analytics endpoint should pass limit=None"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify that all 600 transactions are returned
|
||||||
|
assert data["success"] is True
|
||||||
|
transactions_data = data["data"]
|
||||||
|
assert len(transactions_data) == 600, (
|
||||||
|
"Analytics endpoint should return all 600 transactions"
|
||||||
|
)
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
"""Tests for accounts API endpoints."""
|
"""Tests for accounts API endpoints."""
|
||||||
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.api
|
@pytest.mark.api
|
||||||
class TestAccountsAPI:
|
class TestAccountsAPI:
|
||||||
@@ -43,13 +44,13 @@ class TestAccountsAPI:
|
|||||||
]
|
]
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("leggend.config.config", mock_config),
|
patch("leggen.utils.config.config", mock_config),
|
||||||
patch(
|
patch(
|
||||||
"leggend.api.routes.accounts.database_service.get_accounts_from_db",
|
"leggen.api.routes.accounts.database_service.get_accounts_from_db",
|
||||||
return_value=mock_accounts,
|
return_value=mock_accounts,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"leggend.api.routes.accounts.database_service.get_balances_from_db",
|
"leggen.api.routes.accounts.database_service.get_balances_from_db",
|
||||||
return_value=mock_balances,
|
return_value=mock_balances,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@@ -98,13 +99,13 @@ class TestAccountsAPI:
|
|||||||
]
|
]
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("leggend.config.config", mock_config),
|
patch("leggen.utils.config.config", mock_config),
|
||||||
patch(
|
patch(
|
||||||
"leggend.api.routes.accounts.database_service.get_account_details_from_db",
|
"leggen.api.routes.accounts.database_service.get_account_details_from_db",
|
||||||
return_value=mock_account,
|
return_value=mock_account,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"leggend.api.routes.accounts.database_service.get_balances_from_db",
|
"leggen.api.routes.accounts.database_service.get_balances_from_db",
|
||||||
return_value=mock_balances,
|
return_value=mock_balances,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@@ -148,9 +149,9 @@ class TestAccountsAPI:
|
|||||||
]
|
]
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("leggend.config.config", mock_config),
|
patch("leggen.utils.config.config", mock_config),
|
||||||
patch(
|
patch(
|
||||||
"leggend.api.routes.accounts.database_service.get_balances_from_db",
|
"leggen.api.routes.accounts.database_service.get_balances_from_db",
|
||||||
return_value=mock_balances,
|
return_value=mock_balances,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@@ -176,6 +177,7 @@ class TestAccountsAPI:
|
|||||||
"""Test successful retrieval of account transactions from database."""
|
"""Test successful retrieval of account transactions from database."""
|
||||||
mock_transactions = [
|
mock_transactions = [
|
||||||
{
|
{
|
||||||
|
"transactionId": "txn-bank-123", # NEW: stable bank-provided ID
|
||||||
"internalTransactionId": "txn-123",
|
"internalTransactionId": "txn-123",
|
||||||
"institutionId": "REVOLUT_REVOLT21",
|
"institutionId": "REVOLUT_REVOLT21",
|
||||||
"iban": "LT313250081177977789",
|
"iban": "LT313250081177977789",
|
||||||
@@ -185,18 +187,18 @@ class TestAccountsAPI:
|
|||||||
"transactionCurrency": "EUR",
|
"transactionCurrency": "EUR",
|
||||||
"transactionStatus": "booked",
|
"transactionStatus": "booked",
|
||||||
"accountId": "test-account-123",
|
"accountId": "test-account-123",
|
||||||
"rawTransaction": {"some": "data"},
|
"rawTransaction": {"transactionId": "txn-bank-123", "some": "data"},
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("leggend.config.config", mock_config),
|
patch("leggen.utils.config.config", mock_config),
|
||||||
patch(
|
patch(
|
||||||
"leggend.api.routes.accounts.database_service.get_transactions_from_db",
|
"leggen.api.routes.accounts.database_service.get_transactions_from_db",
|
||||||
return_value=mock_transactions,
|
return_value=mock_transactions,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"leggend.api.routes.accounts.database_service.get_transaction_count_from_db",
|
"leggen.api.routes.accounts.database_service.get_transaction_count_from_db",
|
||||||
return_value=1,
|
return_value=1,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@@ -227,6 +229,7 @@ class TestAccountsAPI:
|
|||||||
"""Test retrieval of full transaction details from database."""
|
"""Test retrieval of full transaction details from database."""
|
||||||
mock_transactions = [
|
mock_transactions = [
|
||||||
{
|
{
|
||||||
|
"transactionId": "txn-bank-123", # NEW: stable bank-provided ID
|
||||||
"internalTransactionId": "txn-123",
|
"internalTransactionId": "txn-123",
|
||||||
"institutionId": "REVOLUT_REVOLT21",
|
"institutionId": "REVOLUT_REVOLT21",
|
||||||
"iban": "LT313250081177977789",
|
"iban": "LT313250081177977789",
|
||||||
@@ -236,18 +239,18 @@ class TestAccountsAPI:
|
|||||||
"transactionCurrency": "EUR",
|
"transactionCurrency": "EUR",
|
||||||
"transactionStatus": "booked",
|
"transactionStatus": "booked",
|
||||||
"accountId": "test-account-123",
|
"accountId": "test-account-123",
|
||||||
"rawTransaction": {"some": "raw_data"},
|
"rawTransaction": {"transactionId": "txn-bank-123", "some": "raw_data"},
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("leggend.config.config", mock_config),
|
patch("leggen.utils.config.config", mock_config),
|
||||||
patch(
|
patch(
|
||||||
"leggend.api.routes.accounts.database_service.get_transactions_from_db",
|
"leggen.api.routes.accounts.database_service.get_transactions_from_db",
|
||||||
return_value=mock_transactions,
|
return_value=mock_transactions,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"leggend.api.routes.accounts.database_service.get_transaction_count_from_db",
|
"leggen.api.routes.accounts.database_service.get_transaction_count_from_db",
|
||||||
return_value=1,
|
return_value=1,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@@ -271,9 +274,9 @@ class TestAccountsAPI:
|
|||||||
):
|
):
|
||||||
"""Test handling of non-existent account."""
|
"""Test handling of non-existent account."""
|
||||||
with (
|
with (
|
||||||
patch("leggend.config.config", mock_config),
|
patch("leggen.utils.config.config", mock_config),
|
||||||
patch(
|
patch(
|
||||||
"leggend.api.routes.accounts.database_service.get_account_details_from_db",
|
"leggen.api.routes.accounts.database_service.get_account_details_from_db",
|
||||||
return_value=None,
|
return_value=None,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"""Tests for banks API endpoints."""
|
"""Tests for banks API endpoints."""
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
import respx
|
import respx
|
||||||
import httpx
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.api
|
@pytest.mark.api
|
||||||
@@ -27,7 +28,7 @@ class TestBanksAPI:
|
|||||||
return_value=httpx.Response(200, json=sample_bank_data)
|
return_value=httpx.Response(200, json=sample_bank_data)
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch("leggend.config.config", mock_config):
|
with patch("leggen.utils.config.config", mock_config):
|
||||||
response = api_client.get("/api/v1/banks/institutions?country=PT")
|
response = api_client.get("/api/v1/banks/institutions?country=PT")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -52,7 +53,7 @@ class TestBanksAPI:
|
|||||||
return_value=httpx.Response(200, json=[])
|
return_value=httpx.Response(200, json=[])
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch("leggend.config.config", mock_config):
|
with patch("leggen.utils.config.config", mock_config):
|
||||||
response = api_client.get("/api/v1/banks/institutions?country=XX")
|
response = api_client.get("/api/v1/banks/institutions?country=XX")
|
||||||
|
|
||||||
# Should still work but return empty or filtered results
|
# Should still work but return empty or filtered results
|
||||||
@@ -86,7 +87,7 @@ class TestBanksAPI:
|
|||||||
"redirect_url": "http://localhost:8000/",
|
"redirect_url": "http://localhost:8000/",
|
||||||
}
|
}
|
||||||
|
|
||||||
with patch("leggend.config.config", mock_config):
|
with patch("leggen.utils.config.config", mock_config):
|
||||||
response = api_client.post("/api/v1/banks/connect", json=request_data)
|
response = api_client.post("/api/v1/banks/connect", json=request_data)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -122,7 +123,7 @@ class TestBanksAPI:
|
|||||||
return_value=httpx.Response(200, json=requisitions_data)
|
return_value=httpx.Response(200, json=requisitions_data)
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch("leggend.config.config", mock_config):
|
with patch("leggen.utils.config.config", mock_config):
|
||||||
response = api_client.get("/api/v1/banks/status")
|
response = api_client.get("/api/v1/banks/status")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -155,7 +156,7 @@ class TestBanksAPI:
|
|||||||
return_value=httpx.Response(401, json={"detail": "Invalid credentials"})
|
return_value=httpx.Response(401, json={"detail": "Invalid credentials"})
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch("leggend.config.config", mock_config):
|
with patch("leggen.utils.config.config", mock_config):
|
||||||
response = api_client.get("/api/v1/banks/institutions")
|
response = api_client.get("/api/v1/banks/institutions")
|
||||||
|
|
||||||
assert response.status_code == 500
|
assert response.status_code == 500
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
"""Tests for CLI API client."""
|
"""Tests for CLI API client."""
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import requests
|
import requests
|
||||||
import requests_mock
|
import requests_mock
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from leggen.api_client import LeggendAPIClient
|
from leggen.api_client import LeggenAPIClient
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.cli
|
@pytest.mark.cli
|
||||||
class TestLeggendAPIClient:
|
class TestLeggenAPIClient:
|
||||||
"""Test the CLI API client."""
|
"""Test the CLI API client."""
|
||||||
|
|
||||||
def test_health_check_success(self):
|
def test_health_check_success(self):
|
||||||
"""Test successful health check."""
|
"""Test successful health check."""
|
||||||
client = LeggendAPIClient("http://localhost:8000")
|
client = LeggenAPIClient("http://localhost:8000")
|
||||||
|
|
||||||
with requests_mock.Mocker() as m:
|
with requests_mock.Mocker() as m:
|
||||||
m.get("http://localhost:8000/health", json={"status": "healthy"})
|
m.get("http://localhost:8000/health", json={"status": "healthy"})
|
||||||
@@ -24,7 +25,7 @@ class TestLeggendAPIClient:
|
|||||||
|
|
||||||
def test_health_check_failure(self):
|
def test_health_check_failure(self):
|
||||||
"""Test health check failure."""
|
"""Test health check failure."""
|
||||||
client = LeggendAPIClient("http://localhost:8000")
|
client = LeggenAPIClient("http://localhost:8000")
|
||||||
|
|
||||||
with requests_mock.Mocker() as m:
|
with requests_mock.Mocker() as m:
|
||||||
m.get("http://localhost:8000/health", status_code=500)
|
m.get("http://localhost:8000/health", status_code=500)
|
||||||
@@ -34,7 +35,7 @@ class TestLeggendAPIClient:
|
|||||||
|
|
||||||
def test_get_institutions_success(self, sample_bank_data):
|
def test_get_institutions_success(self, sample_bank_data):
|
||||||
"""Test getting institutions via API client."""
|
"""Test getting institutions via API client."""
|
||||||
client = LeggendAPIClient("http://localhost:8000")
|
client = LeggenAPIClient("http://localhost:8000")
|
||||||
|
|
||||||
api_response = {
|
api_response = {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -51,7 +52,7 @@ class TestLeggendAPIClient:
|
|||||||
|
|
||||||
def test_get_accounts_success(self, sample_account_data):
|
def test_get_accounts_success(self, sample_account_data):
|
||||||
"""Test getting accounts via API client."""
|
"""Test getting accounts via API client."""
|
||||||
client = LeggendAPIClient("http://localhost:8000")
|
client = LeggenAPIClient("http://localhost:8000")
|
||||||
|
|
||||||
api_response = {
|
api_response = {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -68,7 +69,7 @@ class TestLeggendAPIClient:
|
|||||||
|
|
||||||
def test_trigger_sync_success(self):
|
def test_trigger_sync_success(self):
|
||||||
"""Test triggering sync via API client."""
|
"""Test triggering sync via API client."""
|
||||||
client = LeggendAPIClient("http://localhost:8000")
|
client = LeggenAPIClient("http://localhost:8000")
|
||||||
|
|
||||||
api_response = {
|
api_response = {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -84,14 +85,14 @@ class TestLeggendAPIClient:
|
|||||||
|
|
||||||
def test_connection_error_handling(self):
|
def test_connection_error_handling(self):
|
||||||
"""Test handling of connection errors."""
|
"""Test handling of connection errors."""
|
||||||
client = LeggendAPIClient("http://localhost:9999") # Non-existent service
|
client = LeggenAPIClient("http://localhost:9999") # Non-existent service
|
||||||
|
|
||||||
with pytest.raises((requests.ConnectionError, requests.RequestException)):
|
with pytest.raises((requests.ConnectionError, requests.RequestException)):
|
||||||
client.get_accounts()
|
client.get_accounts()
|
||||||
|
|
||||||
def test_http_error_handling(self):
|
def test_http_error_handling(self):
|
||||||
"""Test handling of HTTP errors."""
|
"""Test handling of HTTP errors."""
|
||||||
client = LeggendAPIClient("http://localhost:8000")
|
client = LeggenAPIClient("http://localhost:8000")
|
||||||
|
|
||||||
with requests_mock.Mocker() as m:
|
with requests_mock.Mocker() as m:
|
||||||
m.get(
|
m.get(
|
||||||
@@ -106,19 +107,19 @@ class TestLeggendAPIClient:
|
|||||||
def test_custom_api_url(self):
|
def test_custom_api_url(self):
|
||||||
"""Test using custom API URL."""
|
"""Test using custom API URL."""
|
||||||
custom_url = "http://custom-host:9000"
|
custom_url = "http://custom-host:9000"
|
||||||
client = LeggendAPIClient(custom_url)
|
client = LeggenAPIClient(custom_url)
|
||||||
|
|
||||||
assert client.base_url == custom_url
|
assert client.base_url == custom_url
|
||||||
|
|
||||||
def test_environment_variable_url(self):
|
def test_environment_variable_url(self):
|
||||||
"""Test using environment variable for API URL."""
|
"""Test using environment variable for API URL."""
|
||||||
with patch.dict("os.environ", {"LEGGEND_API_URL": "http://env-host:7000"}):
|
with patch.dict("os.environ", {"LEGGEN_API_URL": "http://env-host:7000"}):
|
||||||
client = LeggendAPIClient()
|
client = LeggenAPIClient()
|
||||||
assert client.base_url == "http://env-host:7000"
|
assert client.base_url == "http://env-host:7000"
|
||||||
|
|
||||||
def test_sync_with_options(self):
|
def test_sync_with_options(self):
|
||||||
"""Test sync with various options."""
|
"""Test sync with various options."""
|
||||||
client = LeggendAPIClient("http://localhost:8000")
|
client = LeggenAPIClient("http://localhost:8000")
|
||||||
|
|
||||||
api_response = {
|
api_response = {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -135,7 +136,7 @@ class TestLeggendAPIClient:
|
|||||||
|
|
||||||
def test_get_scheduler_config(self):
|
def test_get_scheduler_config(self):
|
||||||
"""Test getting scheduler configuration."""
|
"""Test getting scheduler configuration."""
|
||||||
client = LeggendAPIClient("http://localhost:8000")
|
client = LeggenAPIClient("http://localhost:8000")
|
||||||
|
|
||||||
api_response = {
|
api_response = {
|
||||||
"success": True,
|
"success": True,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""Tests for transactions API endpoints."""
|
"""Tests for transactions API endpoints."""
|
||||||
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import patch
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.api
|
@pytest.mark.api
|
||||||
@@ -43,13 +44,13 @@ class TestTransactionsAPI:
|
|||||||
]
|
]
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("leggend.config.config", mock_config),
|
patch("leggen.utils.config.config", mock_config),
|
||||||
patch(
|
patch(
|
||||||
"leggend.api.routes.transactions.database_service.get_transactions_from_db",
|
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
|
||||||
return_value=mock_transactions,
|
return_value=mock_transactions,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"leggend.api.routes.transactions.database_service.get_transaction_count_from_db",
|
"leggen.api.routes.transactions.database_service.get_transaction_count_from_db",
|
||||||
return_value=2,
|
return_value=2,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@@ -90,13 +91,13 @@ class TestTransactionsAPI:
|
|||||||
]
|
]
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("leggend.config.config", mock_config),
|
patch("leggen.utils.config.config", mock_config),
|
||||||
patch(
|
patch(
|
||||||
"leggend.api.routes.transactions.database_service.get_transactions_from_db",
|
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
|
||||||
return_value=mock_transactions,
|
return_value=mock_transactions,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"leggend.api.routes.transactions.database_service.get_transaction_count_from_db",
|
"leggen.api.routes.transactions.database_service.get_transaction_count_from_db",
|
||||||
return_value=1,
|
return_value=1,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@@ -135,13 +136,13 @@ class TestTransactionsAPI:
|
|||||||
]
|
]
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("leggend.config.config", mock_config),
|
patch("leggen.utils.config.config", mock_config),
|
||||||
patch(
|
patch(
|
||||||
"leggend.api.routes.transactions.database_service.get_transactions_from_db",
|
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
|
||||||
return_value=mock_transactions,
|
return_value=mock_transactions,
|
||||||
) as mock_get_transactions,
|
) as mock_get_transactions,
|
||||||
patch(
|
patch(
|
||||||
"leggend.api.routes.transactions.database_service.get_transaction_count_from_db",
|
"leggen.api.routes.transactions.database_service.get_transaction_count_from_db",
|
||||||
return_value=1,
|
return_value=1,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@@ -153,8 +154,8 @@ class TestTransactionsAPI:
|
|||||||
"min_amount=-50.0&"
|
"min_amount=-50.0&"
|
||||||
"max_amount=0.0&"
|
"max_amount=0.0&"
|
||||||
"search=Coffee&"
|
"search=Coffee&"
|
||||||
"limit=10&"
|
"page=2&"
|
||||||
"offset=5"
|
"per_page=10"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -165,7 +166,7 @@ class TestTransactionsAPI:
|
|||||||
mock_get_transactions.assert_called_once_with(
|
mock_get_transactions.assert_called_once_with(
|
||||||
account_id="test-account-123",
|
account_id="test-account-123",
|
||||||
limit=10,
|
limit=10,
|
||||||
offset=5,
|
offset=10, # (page-1) * per_page = (2-1) * 10 = 10
|
||||||
date_from="2025-09-01",
|
date_from="2025-09-01",
|
||||||
date_to="2025-09-02",
|
date_to="2025-09-02",
|
||||||
min_amount=-50.0,
|
min_amount=-50.0,
|
||||||
@@ -178,13 +179,13 @@ class TestTransactionsAPI:
|
|||||||
):
|
):
|
||||||
"""Test getting transactions when database returns empty result."""
|
"""Test getting transactions when database returns empty result."""
|
||||||
with (
|
with (
|
||||||
patch("leggend.config.config", mock_config),
|
patch("leggen.utils.config.config", mock_config),
|
||||||
patch(
|
patch(
|
||||||
"leggend.api.routes.transactions.database_service.get_transactions_from_db",
|
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
|
||||||
return_value=[],
|
return_value=[],
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"leggend.api.routes.transactions.database_service.get_transaction_count_from_db",
|
"leggen.api.routes.transactions.database_service.get_transaction_count_from_db",
|
||||||
return_value=0,
|
return_value=0,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@@ -194,16 +195,18 @@ class TestTransactionsAPI:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["success"] is True
|
assert data["success"] is True
|
||||||
assert len(data["data"]) == 0
|
assert len(data["data"]) == 0
|
||||||
assert "0 transactions" in data["message"]
|
assert data["pagination"]["total"] == 0
|
||||||
|
assert data["pagination"]["page"] == 1
|
||||||
|
assert data["pagination"]["total_pages"] == 0
|
||||||
|
|
||||||
def test_get_transactions_database_error(
|
def test_get_transactions_database_error(
|
||||||
self, api_client, mock_config, mock_auth_token
|
self, api_client, mock_config, mock_auth_token
|
||||||
):
|
):
|
||||||
"""Test handling database error when getting transactions."""
|
"""Test handling database error when getting transactions."""
|
||||||
with (
|
with (
|
||||||
patch("leggend.config.config", mock_config),
|
patch("leggen.utils.config.config", mock_config),
|
||||||
patch(
|
patch(
|
||||||
"leggend.api.routes.transactions.database_service.get_transactions_from_db",
|
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
|
||||||
side_effect=Exception("Database connection failed"),
|
side_effect=Exception("Database connection failed"),
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@@ -241,9 +244,9 @@ class TestTransactionsAPI:
|
|||||||
]
|
]
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("leggend.config.config", mock_config),
|
patch("leggen.utils.config.config", mock_config),
|
||||||
patch(
|
patch(
|
||||||
"leggend.api.routes.transactions.database_service.get_transactions_from_db",
|
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
|
||||||
return_value=mock_transactions,
|
return_value=mock_transactions,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@@ -282,9 +285,9 @@ class TestTransactionsAPI:
|
|||||||
]
|
]
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("leggend.config.config", mock_config),
|
patch("leggen.utils.config.config", mock_config),
|
||||||
patch(
|
patch(
|
||||||
"leggend.api.routes.transactions.database_service.get_transactions_from_db",
|
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
|
||||||
return_value=mock_transactions,
|
return_value=mock_transactions,
|
||||||
) as mock_get_transactions,
|
) as mock_get_transactions,
|
||||||
):
|
):
|
||||||
@@ -304,9 +307,9 @@ class TestTransactionsAPI:
|
|||||||
):
|
):
|
||||||
"""Test getting stats when no transactions match criteria."""
|
"""Test getting stats when no transactions match criteria."""
|
||||||
with (
|
with (
|
||||||
patch("leggend.config.config", mock_config),
|
patch("leggen.utils.config.config", mock_config),
|
||||||
patch(
|
patch(
|
||||||
"leggend.api.routes.transactions.database_service.get_transactions_from_db",
|
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
|
||||||
return_value=[],
|
return_value=[],
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@@ -329,9 +332,9 @@ class TestTransactionsAPI:
|
|||||||
):
|
):
|
||||||
"""Test handling database error when getting stats."""
|
"""Test handling database error when getting stats."""
|
||||||
with (
|
with (
|
||||||
patch("leggend.config.config", mock_config),
|
patch("leggen.utils.config.config", mock_config),
|
||||||
patch(
|
patch(
|
||||||
"leggend.api.routes.transactions.database_service.get_transactions_from_db",
|
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
|
||||||
side_effect=Exception("Database connection failed"),
|
side_effect=Exception("Database connection failed"),
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@@ -355,9 +358,9 @@ class TestTransactionsAPI:
|
|||||||
]
|
]
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("leggend.config.config", mock_config),
|
patch("leggen.utils.config.config", mock_config),
|
||||||
patch(
|
patch(
|
||||||
"leggend.api.routes.transactions.database_service.get_transactions_from_db",
|
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
|
||||||
return_value=mock_transactions,
|
return_value=mock_transactions,
|
||||||
) as mock_get_transactions,
|
) as mock_get_transactions,
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"""Tests for configuration management."""
|
"""Tests for configuration management."""
|
||||||
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from leggend.config import Config
|
import pytest
|
||||||
|
|
||||||
|
from leggen.utils.config import Config
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@@ -37,10 +38,14 @@ class TestConfig:
|
|||||||
# Reset singleton state for testing
|
# Reset singleton state for testing
|
||||||
config._config = None
|
config._config = None
|
||||||
config._config_path = None
|
config._config_path = None
|
||||||
|
config._config_model = None
|
||||||
|
|
||||||
result = config.load_config(str(config_file))
|
result = config.load_config(str(config_file))
|
||||||
|
|
||||||
assert result == config_data
|
# Result should contain validated config data
|
||||||
|
assert result["gocardless"]["key"] == "test-key"
|
||||||
|
assert result["gocardless"]["secret"] == "test-secret"
|
||||||
|
assert result["database"]["sqlite"] is True
|
||||||
assert config.gocardless_config["key"] == "test-key"
|
assert config.gocardless_config["key"] == "test-key"
|
||||||
assert config.database_config["sqlite"] is True
|
assert config.database_config["sqlite"] is True
|
||||||
|
|
||||||
@@ -54,11 +59,19 @@ class TestConfig:
|
|||||||
|
|
||||||
def test_save_config_success(self, temp_config_dir):
|
def test_save_config_success(self, temp_config_dir):
|
||||||
"""Test successful configuration saving."""
|
"""Test successful configuration saving."""
|
||||||
config_data = {"gocardless": {"key": "new-key", "secret": "new-secret"}}
|
config_data = {
|
||||||
|
"gocardless": {
|
||||||
|
"key": "new-key",
|
||||||
|
"secret": "new-secret",
|
||||||
|
"url": "https://bankaccountdata.gocardless.com/api/v2",
|
||||||
|
},
|
||||||
|
"database": {"sqlite": True},
|
||||||
|
}
|
||||||
|
|
||||||
config_file = temp_config_dir / "new_config.toml"
|
config_file = temp_config_dir / "new_config.toml"
|
||||||
config = Config()
|
config = Config()
|
||||||
config._config = None
|
config._config = None
|
||||||
|
config._config_model = None
|
||||||
|
|
||||||
config.save_config(config_data, str(config_file))
|
config.save_config(config_data, str(config_file))
|
||||||
|
|
||||||
@@ -70,12 +83,18 @@ class TestConfig:
|
|||||||
with open(config_file, "rb") as f:
|
with open(config_file, "rb") as f:
|
||||||
saved_data = tomllib.load(f)
|
saved_data = tomllib.load(f)
|
||||||
|
|
||||||
assert saved_data == config_data
|
assert saved_data["gocardless"]["key"] == "new-key"
|
||||||
|
assert saved_data["gocardless"]["secret"] == "new-secret"
|
||||||
|
assert saved_data["database"]["sqlite"] is True
|
||||||
|
|
||||||
def test_update_config_success(self, temp_config_dir):
|
def test_update_config_success(self, temp_config_dir):
|
||||||
"""Test updating configuration values."""
|
"""Test updating configuration values."""
|
||||||
initial_config = {
|
initial_config = {
|
||||||
"gocardless": {"key": "old-key"},
|
"gocardless": {
|
||||||
|
"key": "old-key",
|
||||||
|
"secret": "old-secret",
|
||||||
|
"url": "https://bankaccountdata.gocardless.com/api/v2",
|
||||||
|
},
|
||||||
"database": {"sqlite": True},
|
"database": {"sqlite": True},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +106,7 @@ class TestConfig:
|
|||||||
|
|
||||||
config = Config()
|
config = Config()
|
||||||
config._config = None
|
config._config = None
|
||||||
|
config._config_model = None
|
||||||
config.load_config(str(config_file))
|
config.load_config(str(config_file))
|
||||||
|
|
||||||
config.update_config("gocardless", "key", "new-key")
|
config.update_config("gocardless", "key", "new-key")
|
||||||
@@ -102,7 +122,14 @@ class TestConfig:
|
|||||||
|
|
||||||
def test_update_section_success(self, temp_config_dir):
|
def test_update_section_success(self, temp_config_dir):
|
||||||
"""Test updating entire configuration section."""
|
"""Test updating entire configuration section."""
|
||||||
initial_config = {"database": {"sqlite": True}}
|
initial_config = {
|
||||||
|
"gocardless": {
|
||||||
|
"key": "test-key",
|
||||||
|
"secret": "test-secret",
|
||||||
|
"url": "https://bankaccountdata.gocardless.com/api/v2",
|
||||||
|
},
|
||||||
|
"database": {"sqlite": True},
|
||||||
|
}
|
||||||
|
|
||||||
config_file = temp_config_dir / "config.toml"
|
config_file = temp_config_dir / "config.toml"
|
||||||
with open(config_file, "wb") as f:
|
with open(config_file, "wb") as f:
|
||||||
@@ -112,12 +139,13 @@ class TestConfig:
|
|||||||
|
|
||||||
config = Config()
|
config = Config()
|
||||||
config._config = None
|
config._config = None
|
||||||
|
config._config_model = None
|
||||||
config.load_config(str(config_file))
|
config.load_config(str(config_file))
|
||||||
|
|
||||||
new_db_config = {"sqlite": False, "path": "./custom.db"}
|
new_db_config = {"sqlite": False}
|
||||||
config.update_section("database", new_db_config)
|
config.update_section("database", new_db_config)
|
||||||
|
|
||||||
assert config.database_config == new_db_config
|
assert config.database_config["sqlite"] is False
|
||||||
|
|
||||||
def test_scheduler_config_defaults(self):
|
def test_scheduler_config_defaults(self):
|
||||||
"""Test scheduler configuration with defaults."""
|
"""Test scheduler configuration with defaults."""
|
||||||
|
|||||||
173
tests/unit/test_configurable_paths.py
Normal file
173
tests/unit/test_configurable_paths.py
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
"""Integration tests for configurable paths."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from leggen.services.database_service import DatabaseService
|
||||||
|
from leggen.utils.paths import path_manager
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestConfigurablePaths:
|
||||||
|
"""Test configurable path management."""
|
||||||
|
|
||||||
|
def test_default_paths(self):
|
||||||
|
"""Test that default paths are correctly set."""
|
||||||
|
# Reset path manager
|
||||||
|
original_config = path_manager._config_dir
|
||||||
|
original_db = path_manager._database_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
path_manager._config_dir = None
|
||||||
|
path_manager._database_path = None
|
||||||
|
|
||||||
|
# Test defaults
|
||||||
|
config_dir = path_manager.get_config_dir()
|
||||||
|
db_path = path_manager.get_database_path()
|
||||||
|
|
||||||
|
assert config_dir == Path.home() / ".config" / "leggen"
|
||||||
|
assert db_path == Path.home() / ".config" / "leggen" / "leggen.db"
|
||||||
|
finally:
|
||||||
|
path_manager._config_dir = original_config
|
||||||
|
path_manager._database_path = original_db
|
||||||
|
|
||||||
|
def test_environment_variables(self):
|
||||||
|
"""Test that environment variables override defaults."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
test_config_dir = Path(tmpdir) / "test-config"
|
||||||
|
test_db_path = Path(tmpdir) / "test.db"
|
||||||
|
|
||||||
|
with patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"LEGGEN_CONFIG_DIR": str(test_config_dir),
|
||||||
|
"LEGGEN_DATABASE_PATH": str(test_db_path),
|
||||||
|
},
|
||||||
|
):
|
||||||
|
# Reset path manager to pick up environment variables
|
||||||
|
original_config = path_manager._config_dir
|
||||||
|
original_db = path_manager._database_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
path_manager._config_dir = None
|
||||||
|
path_manager._database_path = None
|
||||||
|
|
||||||
|
config_dir = path_manager.get_config_dir()
|
||||||
|
db_path = path_manager.get_database_path()
|
||||||
|
|
||||||
|
assert config_dir == test_config_dir
|
||||||
|
assert db_path == test_db_path
|
||||||
|
finally:
|
||||||
|
path_manager._config_dir = original_config
|
||||||
|
path_manager._database_path = original_db
|
||||||
|
|
||||||
|
def test_explicit_path_setting(self):
|
||||||
|
"""Test explicitly setting paths."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
test_config_dir = Path(tmpdir) / "explicit-config"
|
||||||
|
test_db_path = Path(tmpdir) / "explicit.db"
|
||||||
|
|
||||||
|
# Save original paths
|
||||||
|
original_config = path_manager._config_dir
|
||||||
|
original_db = path_manager._database_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Set explicit paths
|
||||||
|
path_manager.set_config_dir(test_config_dir)
|
||||||
|
path_manager.set_database_path(test_db_path)
|
||||||
|
|
||||||
|
assert path_manager.get_config_dir() == test_config_dir
|
||||||
|
assert path_manager.get_database_path() == test_db_path
|
||||||
|
assert (
|
||||||
|
path_manager.get_config_file_path()
|
||||||
|
== test_config_dir / "config.toml"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
path_manager.get_auth_file_path() == test_config_dir / "auth.json"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
# Restore original paths
|
||||||
|
path_manager._config_dir = original_config
|
||||||
|
path_manager._database_path = original_db
|
||||||
|
|
||||||
|
def test_database_operations_with_custom_path(self):
|
||||||
|
"""Test that database operations work with custom paths."""
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp_file:
|
||||||
|
test_db_path = Path(tmp_file.name)
|
||||||
|
|
||||||
|
# Save original database path
|
||||||
|
original_db = path_manager._database_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Set custom database path
|
||||||
|
path_manager.set_database_path(test_db_path)
|
||||||
|
|
||||||
|
# Test database operations using DatabaseService
|
||||||
|
database_service = DatabaseService()
|
||||||
|
balance_data = {
|
||||||
|
"balances": [
|
||||||
|
{
|
||||||
|
"balanceAmount": {"amount": "1000.0", "currency": "EUR"},
|
||||||
|
"balanceType": "available",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"institution_id": "TEST_BANK",
|
||||||
|
"account_status": "active",
|
||||||
|
"iban": "TEST_IBAN",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Use the internal balance persistence method since the test needs direct database access
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
database_service._persist_balance_sqlite("test-account", balance_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Retrieve balances
|
||||||
|
balances = asyncio.run(
|
||||||
|
database_service.get_balances_from_db("test-account")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(balances) == 1
|
||||||
|
assert balances[0]["account_id"] == "test-account"
|
||||||
|
assert balances[0]["amount"] == 1000.0
|
||||||
|
|
||||||
|
# Verify database file exists at custom location
|
||||||
|
assert test_db_path.exists()
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Restore original path and cleanup
|
||||||
|
path_manager._database_path = original_db
|
||||||
|
if test_db_path.exists():
|
||||||
|
test_db_path.unlink()
|
||||||
|
|
||||||
|
def test_directory_creation(self):
|
||||||
|
"""Test that directories are created as needed."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
test_config_dir = Path(tmpdir) / "new" / "config" / "dir"
|
||||||
|
test_db_path = Path(tmpdir) / "new" / "db" / "dir" / "test.db"
|
||||||
|
|
||||||
|
# Save original paths
|
||||||
|
original_config = path_manager._config_dir
|
||||||
|
original_db = path_manager._database_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Set paths to non-existent directories
|
||||||
|
path_manager.set_config_dir(test_config_dir)
|
||||||
|
path_manager.set_database_path(test_db_path)
|
||||||
|
|
||||||
|
# Ensure directories are created
|
||||||
|
path_manager.ensure_config_dir_exists()
|
||||||
|
path_manager.ensure_database_dir_exists()
|
||||||
|
|
||||||
|
assert test_config_dir.exists()
|
||||||
|
assert test_db_path.parent.exists()
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Restore original paths
|
||||||
|
path_manager._config_dir = original_config
|
||||||
|
path_manager._database_path = original_db
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
"""Tests for database service."""
|
"""Tests for database service."""
|
||||||
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import patch
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from leggend.services.database_service import DatabaseService
|
import pytest
|
||||||
|
|
||||||
|
from leggen.services.database_service import DatabaseService
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -83,7 +84,9 @@ class TestDatabaseService:
|
|||||||
self, database_service, sample_transactions_db_format
|
self, database_service, sample_transactions_db_format
|
||||||
):
|
):
|
||||||
"""Test successful retrieval of transactions from database."""
|
"""Test successful retrieval of transactions from database."""
|
||||||
with patch("leggen.database.sqlite.get_transactions") as mock_get_transactions:
|
with patch.object(
|
||||||
|
database_service, "_get_transactions"
|
||||||
|
) as mock_get_transactions:
|
||||||
mock_get_transactions.return_value = sample_transactions_db_format
|
mock_get_transactions.return_value = sample_transactions_db_format
|
||||||
|
|
||||||
result = await database_service.get_transactions_from_db(
|
result = await database_service.get_transactions_from_db(
|
||||||
@@ -107,7 +110,9 @@ class TestDatabaseService:
|
|||||||
self, database_service, sample_transactions_db_format
|
self, database_service, sample_transactions_db_format
|
||||||
):
|
):
|
||||||
"""Test retrieving transactions with filters."""
|
"""Test retrieving transactions with filters."""
|
||||||
with patch("leggen.database.sqlite.get_transactions") as mock_get_transactions:
|
with patch.object(
|
||||||
|
database_service, "_get_transactions"
|
||||||
|
) as mock_get_transactions:
|
||||||
mock_get_transactions.return_value = sample_transactions_db_format
|
mock_get_transactions.return_value = sample_transactions_db_format
|
||||||
|
|
||||||
result = await database_service.get_transactions_from_db(
|
result = await database_service.get_transactions_from_db(
|
||||||
@@ -143,7 +148,9 @@ class TestDatabaseService:
|
|||||||
|
|
||||||
async def test_get_transactions_from_db_error(self, database_service):
|
async def test_get_transactions_from_db_error(self, database_service):
|
||||||
"""Test handling error when getting transactions."""
|
"""Test handling error when getting transactions."""
|
||||||
with patch("leggen.database.sqlite.get_transactions") as mock_get_transactions:
|
with patch.object(
|
||||||
|
database_service, "_get_transactions"
|
||||||
|
) as mock_get_transactions:
|
||||||
mock_get_transactions.side_effect = Exception("Database error")
|
mock_get_transactions.side_effect = Exception("Database error")
|
||||||
|
|
||||||
result = await database_service.get_transactions_from_db()
|
result = await database_service.get_transactions_from_db()
|
||||||
@@ -152,7 +159,7 @@ class TestDatabaseService:
|
|||||||
|
|
||||||
async def test_get_transaction_count_from_db_success(self, database_service):
|
async def test_get_transaction_count_from_db_success(self, database_service):
|
||||||
"""Test successful retrieval of transaction count."""
|
"""Test successful retrieval of transaction count."""
|
||||||
with patch("leggen.database.sqlite.get_transaction_count") as mock_get_count:
|
with patch.object(database_service, "_get_transaction_count") as mock_get_count:
|
||||||
mock_get_count.return_value = 42
|
mock_get_count.return_value = 42
|
||||||
|
|
||||||
result = await database_service.get_transaction_count_from_db(
|
result = await database_service.get_transaction_count_from_db(
|
||||||
@@ -164,7 +171,7 @@ class TestDatabaseService:
|
|||||||
|
|
||||||
async def test_get_transaction_count_from_db_with_filters(self, database_service):
|
async def test_get_transaction_count_from_db_with_filters(self, database_service):
|
||||||
"""Test getting transaction count with filters."""
|
"""Test getting transaction count with filters."""
|
||||||
with patch("leggen.database.sqlite.get_transaction_count") as mock_get_count:
|
with patch.object(database_service, "_get_transaction_count") as mock_get_count:
|
||||||
mock_get_count.return_value = 15
|
mock_get_count.return_value = 15
|
||||||
|
|
||||||
result = await database_service.get_transaction_count_from_db(
|
result = await database_service.get_transaction_count_from_db(
|
||||||
@@ -194,7 +201,7 @@ class TestDatabaseService:
|
|||||||
|
|
||||||
async def test_get_transaction_count_from_db_error(self, database_service):
|
async def test_get_transaction_count_from_db_error(self, database_service):
|
||||||
"""Test handling error when getting count."""
|
"""Test handling error when getting count."""
|
||||||
with patch("leggen.database.sqlite.get_transaction_count") as mock_get_count:
|
with patch.object(database_service, "_get_transaction_count") as mock_get_count:
|
||||||
mock_get_count.side_effect = Exception("Database error")
|
mock_get_count.side_effect = Exception("Database error")
|
||||||
|
|
||||||
result = await database_service.get_transaction_count_from_db()
|
result = await database_service.get_transaction_count_from_db()
|
||||||
@@ -205,7 +212,7 @@ class TestDatabaseService:
|
|||||||
self, database_service, sample_balances_db_format
|
self, database_service, sample_balances_db_format
|
||||||
):
|
):
|
||||||
"""Test successful retrieval of balances from database."""
|
"""Test successful retrieval of balances from database."""
|
||||||
with patch("leggen.database.sqlite.get_balances") as mock_get_balances:
|
with patch.object(database_service, "_get_balances") as mock_get_balances:
|
||||||
mock_get_balances.return_value = sample_balances_db_format
|
mock_get_balances.return_value = sample_balances_db_format
|
||||||
|
|
||||||
result = await database_service.get_balances_from_db(
|
result = await database_service.get_balances_from_db(
|
||||||
@@ -227,7 +234,7 @@ class TestDatabaseService:
|
|||||||
|
|
||||||
async def test_get_balances_from_db_error(self, database_service):
|
async def test_get_balances_from_db_error(self, database_service):
|
||||||
"""Test handling error when getting balances."""
|
"""Test handling error when getting balances."""
|
||||||
with patch("leggen.database.sqlite.get_balances") as mock_get_balances:
|
with patch.object(database_service, "_get_balances") as mock_get_balances:
|
||||||
mock_get_balances.side_effect = Exception("Database error")
|
mock_get_balances.side_effect = Exception("Database error")
|
||||||
|
|
||||||
result = await database_service.get_balances_from_db()
|
result = await database_service.get_balances_from_db()
|
||||||
@@ -242,7 +249,7 @@ class TestDatabaseService:
|
|||||||
"iban": "LT313250081177977789",
|
"iban": "LT313250081177977789",
|
||||||
}
|
}
|
||||||
|
|
||||||
with patch("leggen.database.sqlite.get_account_summary") as mock_get_summary:
|
with patch.object(database_service, "_get_account_summary") as mock_get_summary:
|
||||||
mock_get_summary.return_value = mock_summary
|
mock_get_summary.return_value = mock_summary
|
||||||
|
|
||||||
result = await database_service.get_account_summary_from_db(
|
result = await database_service.get_account_summary_from_db(
|
||||||
@@ -262,7 +269,7 @@ class TestDatabaseService:
|
|||||||
|
|
||||||
async def test_get_account_summary_from_db_error(self, database_service):
|
async def test_get_account_summary_from_db_error(self, database_service):
|
||||||
"""Test handling error when getting summary."""
|
"""Test handling error when getting summary."""
|
||||||
with patch("leggen.database.sqlite.get_account_summary") as mock_get_summary:
|
with patch.object(database_service, "_get_account_summary") as mock_get_summary:
|
||||||
mock_get_summary.side_effect = Exception("Database error")
|
mock_get_summary.side_effect = Exception("Database error")
|
||||||
|
|
||||||
result = await database_service.get_account_summary_from_db(
|
result = await database_service.get_account_summary_from_db(
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"""Tests for background scheduler."""
|
"""Tests for background scheduler."""
|
||||||
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import patch, AsyncMock, MagicMock
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
from leggend.background.scheduler import BackgroundScheduler
|
import pytest
|
||||||
|
|
||||||
|
from leggen.background.scheduler import BackgroundScheduler
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@@ -20,8 +21,8 @@ class TestBackgroundScheduler:
|
|||||||
def scheduler(self):
|
def scheduler(self):
|
||||||
"""Create scheduler instance for testing."""
|
"""Create scheduler instance for testing."""
|
||||||
with (
|
with (
|
||||||
patch("leggend.background.scheduler.SyncService"),
|
patch("leggen.background.scheduler.SyncService"),
|
||||||
patch("leggend.background.scheduler.config") as mock_config,
|
patch("leggen.background.scheduler.config") as mock_config,
|
||||||
):
|
):
|
||||||
mock_config.scheduler_config = {
|
mock_config.scheduler_config = {
|
||||||
"sync": {"enabled": True, "hour": 3, "minute": 0}
|
"sync": {"enabled": True, "hour": 3, "minute": 0}
|
||||||
@@ -37,7 +38,7 @@ class TestBackgroundScheduler:
|
|||||||
|
|
||||||
def test_scheduler_start_default_config(self, scheduler, mock_config):
|
def test_scheduler_start_default_config(self, scheduler, mock_config):
|
||||||
"""Test starting scheduler with default configuration."""
|
"""Test starting scheduler with default configuration."""
|
||||||
with patch("leggend.config.config") as mock_config_obj:
|
with patch("leggen.utils.config.config") as mock_config_obj:
|
||||||
mock_config_obj.scheduler_config = mock_config
|
mock_config_obj.scheduler_config = mock_config
|
||||||
|
|
||||||
# Mock the job that gets added
|
# Mock the job that gets added
|
||||||
@@ -58,7 +59,7 @@ class TestBackgroundScheduler:
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(scheduler, "scheduler") as mock_scheduler,
|
patch.object(scheduler, "scheduler") as mock_scheduler,
|
||||||
patch("leggend.background.scheduler.config") as mock_config_obj,
|
patch("leggen.background.scheduler.config") as mock_config_obj,
|
||||||
):
|
):
|
||||||
mock_config_obj.scheduler_config = disabled_config
|
mock_config_obj.scheduler_config = disabled_config
|
||||||
mock_scheduler.running = False
|
mock_scheduler.running = False
|
||||||
@@ -79,7 +80,7 @@ class TestBackgroundScheduler:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
with patch("leggend.config.config") as mock_config_obj:
|
with patch("leggen.utils.config.config") as mock_config_obj:
|
||||||
mock_config_obj.scheduler_config = cron_config
|
mock_config_obj.scheduler_config = cron_config
|
||||||
|
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
@@ -97,7 +98,7 @@ class TestBackgroundScheduler:
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(scheduler, "scheduler") as mock_scheduler,
|
patch.object(scheduler, "scheduler") as mock_scheduler,
|
||||||
patch("leggend.background.scheduler.config") as mock_config_obj,
|
patch("leggen.background.scheduler.config") as mock_config_obj,
|
||||||
):
|
):
|
||||||
mock_config_obj.scheduler_config = invalid_cron_config
|
mock_config_obj.scheduler_config = invalid_cron_config
|
||||||
mock_scheduler.running = False
|
mock_scheduler.running = False
|
||||||
@@ -187,7 +188,7 @@ class TestBackgroundScheduler:
|
|||||||
|
|
||||||
def test_scheduler_job_max_instances(self, scheduler, mock_config):
|
def test_scheduler_job_max_instances(self, scheduler, mock_config):
|
||||||
"""Test that sync jobs have max_instances=1."""
|
"""Test that sync jobs have max_instances=1."""
|
||||||
with patch("leggend.config.config") as mock_config_obj:
|
with patch("leggen.utils.config.config") as mock_config_obj:
|
||||||
mock_config_obj.scheduler_config = mock_config
|
mock_config_obj.scheduler_config = mock_config
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user