299 lines
15 KiB
Markdown
299 lines
15 KiB
Markdown
# Finance Tracker — Design Spec
|
||
|
||
**Date:** 2026-03-19
|
||
**Stack:** .NET 8 · MySQL 8 · Vue.js 3 · Vuetify 3 · NSwag · ApexCharts · Docker
|
||
|
||
---
|
||
|
||
## Overview
|
||
|
||
A personal web app for tracking finances. The primary workflow is importing CSV bank exports (ČSOB format) and visualising spending over time. Single-user with a login screen for security. Deployed via docker-compose on a home server behind an existing Nginx reverse proxy.
|
||
|
||
---
|
||
|
||
## Architecture
|
||
|
||
Three docker-compose services sharing an internal network:
|
||
|
||
| Service | Image | Exposed Port | Role |
|
||
|---------|-------|-------------|------|
|
||
| `api` | .NET 8 (custom) | 5000 | REST API + business logic |
|
||
| `web` | Nginx (custom, serves Vue build) | 3000 | Frontend SPA |
|
||
| `db` | MySQL 8 | 3306 (internal only) | Persistence |
|
||
|
||
**Host Nginx routing** — the .NET API mounts all routes under `/api/...`, so Nginx passes the full URI through without stripping the prefix:
|
||
```nginx
|
||
location /api/ { proxy_pass http://localhost:5000; }
|
||
location / { proxy_pass http://localhost:3000; }
|
||
```
|
||
|
||
**CORS** — the API enables CORS for `http://localhost:3000` (dev) and the production frontend origin via the `ALLOWED_ORIGIN` env var.
|
||
|
||
Secrets provided via `.env` file (not committed to git):
|
||
- `DB_CONNECTION` — MySQL connection string
|
||
- `JWT_SECRET` — 32-byte random value, Base64-encoded (e.g. `openssl rand -base64 32`)
|
||
- `APP_USERNAME` / `APP_PASSWORD` — single user credentials (password stored as bcrypt hash in `.env`)
|
||
- `ALLOWED_ORIGIN` — frontend origin for CORS
|
||
|
||
**DB persistence & migrations** — a named Docker volume persists MySQL data across restarts. The `db` service has a health check (`mysqladmin ping`). The `api` service depends on `db` with `condition: service_healthy`. Schema is managed via EF Core migrations, applied automatically at API startup using Polly retry: 5 attempts, exponential backoff (1s, 2s, 4s, 8s, 16s). If all attempts fail, the container exits.
|
||
|
||
---
|
||
|
||
## NSwag Client Generation
|
||
|
||
The TypeScript API client is generated from the .NET OpenAPI spec at build time — no running API or database is needed.
|
||
|
||
**Mechanism:**
|
||
1. `AccountTracking.Api` uses the `NSwag.MSBuild` NuGet package, which generates `openapi.json` into the project output directory as part of `dotnet build`.
|
||
2. The `AccountTracking.Web` Dockerfile is a two-stage build:
|
||
- **Stage 1** (`sdk`): runs `dotnet build` on the API project (no DB needed) — produces `openapi.json`.
|
||
- **Stage 2** (`node`): copies `openapi.json` from stage 1, runs `nswag run nswag.json` to generate the TypeScript client into `src/api/`, then runs `npm run build`.
|
||
3. The final image is Nginx serving the Vue build output.
|
||
|
||
**NSwag client base URL:** configured as `/api` (relative), so it works identically in development (through Vite's dev server proxy) and production (through Nginx proxy). No build-time URL argument needed.
|
||
|
||
**Vite dev proxy** — `vite.config.ts` proxies `/api` → `http://localhost:5000` with no path rewriting (since the API mounts all routes under `/api/...`):
|
||
```ts
|
||
server: {
|
||
proxy: {
|
||
'/api': { target: 'http://localhost:5000', changeOrigin: true }
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Data Model
|
||
|
||
All `DATETIME` columns are stored as UTC. EF Core models use `DateTimeKind.Utc` on datetime properties (required when using Pomelo's MySQL EF Core provider to avoid implicit UTC conversion issues).
|
||
|
||
### `transactions`
|
||
|
||
| Column | Type | Notes |
|
||
|--------|------|-------|
|
||
| id | INT PK AUTO_INCREMENT | |
|
||
| account_number | VARCHAR(30) | e.g. `216868554/0300` |
|
||
| booking_date | DATE | |
|
||
| amount | DECIMAL(15,2) | negative = outgoing |
|
||
| currency | VARCHAR(3) | CZK |
|
||
| balance | DECIMAL(15,2) | account balance after transaction |
|
||
| counter_party_name | VARCHAR(255) | merchant or sender name |
|
||
| operation_description | VARCHAR(255) | e.g. "Transakce platební kartou" |
|
||
| message | TEXT | full description from CSV |
|
||
| category | VARCHAR(100) | from bank CSV, used as-is |
|
||
| variable_symbol | VARCHAR(30) | |
|
||
| bank_note | VARCHAR(255) | "vlastní poznámka" from CSV (read-only, not user-editable) |
|
||
| transaction_id | VARCHAR(512) UNIQUE, collation utf8mb4_bin | case-sensitive; format `transhist-v-YYYY-MM_<sha>` |
|
||
|
||
Additional CSV columns (counter bank code, constant symbol, specific symbol, order name, exchange rate, E2E id, payer reference, original payer, final recipient, original transaction) stored as nullable VARCHAR(255) but not used in UI.
|
||
|
||
### `import_logs`
|
||
|
||
| Column | Type | Notes |
|
||
|--------|------|-------|
|
||
| id | INT PK AUTO_INCREMENT | |
|
||
| imported_at | DATETIME | stored as UTC |
|
||
| filename | VARCHAR(255) | original filename |
|
||
| records_imported | INT | new transactions saved |
|
||
| records_skipped | INT | duplicates ignored |
|
||
|
||
---
|
||
|
||
## ČSOB CSV Format
|
||
|
||
The CSV is semicolon-delimited, UTF-8. Lines 1–2 are a bank-generated title. The parser locates the header row by scanning for the first line that starts with `číslo účtu` (case-insensitive) rather than hardcoding a line number.
|
||
|
||
Column mapping:
|
||
|
||
| CSV Column (Czech) | Model Field |
|
||
|-------------------|-------------|
|
||
| číslo účtu | account_number |
|
||
| datum zaúčtování | booking_date |
|
||
| částka | amount |
|
||
| měna | currency |
|
||
| zůstatek | balance |
|
||
| jméno protistrany | counter_party_name |
|
||
| označení operace | operation_description |
|
||
| zpráva | message |
|
||
| kategorie | category |
|
||
| variabilní symbol | variable_symbol |
|
||
| vlastní poznámka | bank_note |
|
||
| ID transakce | transaction_id |
|
||
|
||
Amount and balance values use Czech locale formatting (comma as decimal separator, space as thousands separator) — normalise before parsing (remove spaces, replace comma with dot).
|
||
|
||
**Error handling:**
|
||
- Header row not found, or required columns (`částka`, `ID transakce`, `datum zaúčtování`) missing → HTTP 400 `{ error: string }`
|
||
- File exceeds 10 MB → HTTP 400 `{ error: "File too large" }` (Kestrel `MaxRequestBodySize` set to 10 MB; framework error caught and returned as structured response)
|
||
- Zero new transactions and zero duplicates → HTTP 200 `{ recordsImported: 0, recordsSkipped: 0 }`
|
||
|
||
---
|
||
|
||
## API Endpoints
|
||
|
||
All endpoints except login require `Authorization: Bearer <token>`. JWT tokens expire after **30 days**; `expiresAt` is an ISO 8601 UTC string (e.g. `"2026-04-18T10:00:00Z"`). The frontend stores both `token` and `expiresAt` in `localStorage`. The Vue Router navigation guard checks `Date.now() < Date.parse(expiresAt)` to detect expiry locally. On any 401 API response, the auth store clears both values and redirects to `/login`.
|
||
|
||
```
|
||
POST /api/auth/login
|
||
Body: { username, password }
|
||
Returns 200: { token, expiresAt }
|
||
Returns 401: wrong credentials
|
||
|
||
POST /api/transactions/import
|
||
Body: multipart/form-data (CSV file, max 10 MB)
|
||
Returns 200: { recordsImported, recordsSkipped }
|
||
Returns 400: { error: string }
|
||
|
||
GET /api/transactions
|
||
Query: year (required), month (optional), category (optional),
|
||
search (optional), page (default 1), pageSize (default 50, max 200)
|
||
— search: case-insensitive contains match (%term%) on counter_party_name
|
||
OR message; uses database collation (utf8mb4_unicode_ci)
|
||
Returns 200: { items: [...], totalCount, page, pageSize }
|
||
|
||
GET /api/transactions/categories
|
||
No query params — returns all-time distinct categories (not period-filtered)
|
||
Returns 200: string[] sorted alphabetically
|
||
|
||
GET /api/dashboard/summary
|
||
Query: year (required), month (optional)
|
||
— if month omitted: totals are for the entire year
|
||
— if month provided: totals are for that month only
|
||
Returns 200: { totalSpent, totalIncome }
|
||
— totalSpent: absolute value of sum of negative amounts for the period
|
||
— totalIncome: sum of positive amounts for the period
|
||
|
||
GET /api/dashboard/spending-by-category
|
||
Query: year (required), month (optional)
|
||
— if month omitted: totals for the entire year
|
||
— if month provided: totals for that month only
|
||
Returns 200: [{ category, total }]
|
||
— total: absolute value of sum of negative amounts for that category in period
|
||
— positive amounts within any category are excluded from totals
|
||
— sorted descending by total
|
||
|
||
GET /api/dashboard/monthly-balances
|
||
Query: year (required)
|
||
Returns 200: [{ month, closingBalance }]
|
||
— one entry per month that has transactions (months with no transactions omitted)
|
||
— closingBalance: balance from the transaction with the latest booking_date
|
||
in that month (highest id as tiebreaker for same-date rows)
|
||
— always year-scoped
|
||
|
||
GET /api/dashboard/cumulative-spending
|
||
Query: year (required), month (required)
|
||
Returns 200: [{ day, cumulativeSpent }]
|
||
— one entry per day that has outgoing transactions within the given month
|
||
— cumulativeSpent: running absolute total of negative amounts from day 1
|
||
through that day (sorted ascending by day)
|
||
— days with no spending are included with the previous day's cumulative value
|
||
so the line chart has no gaps
|
||
```
|
||
|
||
---
|
||
|
||
## Frontend
|
||
|
||
### Pages
|
||
|
||
| Page | Route | Auth Required |
|
||
|------|-------|--------------|
|
||
| Login | `/login` | No |
|
||
| Dashboard | `/` | Yes |
|
||
| Transactions | `/transactions` | Yes |
|
||
|
||
JWT and `expiresAt` stored in `localStorage`. Vue Router navigation guard checks local expiry and redirects unauthenticated/expired users to `/login`. On 401 API response, auth store clears storage and redirects to `/login`.
|
||
|
||
### Dashboard Layout
|
||
|
||
**App bar** — spans full width at top. Contains app title on the left and an "Upload CSV" button on the right. Clicking Upload CSV opens a file picker dialog; the selected file is uploaded via `POST /api/transactions/import` with an inline result snackbar ("Imported X, skipped Y" or error message). No separate import page.
|
||
|
||
**Two-column body** — equal-width columns side by side below the app bar.
|
||
|
||
**Left column — Year Summary**
|
||
- Toolbar: previous-year button, selected year label, next-year button
|
||
- Summary row: Total Expenses (red) | Total Income (green) — year-only totals (see API note below)
|
||
- Two sub-columns:
|
||
- **Doughnut chart** (ApexCharts) — expenses by category for the selected year; legend with category name and amount
|
||
- **Monthly balances bar chart** (ApexCharts) — closing balance per month for the selected year, from `/api/dashboard/monthly-balances`
|
||
|
||
**Right column — Month Summary**
|
||
- Toolbar: previous-month button, selected month+year label, next-month button (wraps year when crossing Jan/Dec boundary)
|
||
- Summary row: Total Expenses (red) | Total Income (green) — from `/api/dashboard/summary` (year+month)
|
||
- Two sub-columns:
|
||
- **Doughnut chart** (ApexCharts) — expenses by category for the selected month; legend with category name and amount
|
||
- **Cumulative spending line chart** (ApexCharts) — running total of expenses day by day through the selected month, from `/api/dashboard/cumulative-spending`
|
||
|
||
All widgets show a skeleton loader while fetching and a subtle error indicator on failure.
|
||
|
||
**Shared period state** — both columns share a single `{ year, month }` value. The toolbars are two views of the same state:
|
||
- Next/prev **month** (right toolbar): increments/decrements the month, wrapping the year (e.g. Dec 2024 → Jan 2025 updates both columns to year=2025, month=1)
|
||
- Next/prev **year** (left toolbar): increments/decrements the year, keeping the current month (e.g. year 2025, month=1 → next year → year=2026, month=1, right column updates to Jan 2026)
|
||
|
||
### Transactions Page
|
||
|
||
Vuetify data table:
|
||
- Columns: Date, Counter Party, Category, Amount, Balance
|
||
- Filters: year (required), month (optional), category dropdown — server-side query params
|
||
- Search box — server-side (`search` query param, debounced 300 ms)
|
||
- Server-side pagination (page, pageSize=50)
|
||
|
||
### State Management (Pinia)
|
||
|
||
- `auth` — token, expiresAt, login/logout actions, 401 handler
|
||
- `dashPeriod` — `{ year, month }` shared by both dashboard columns; next/prev month wraps year, next/prev year keeps month
|
||
- `txPeriod` — selected year/month for the transactions page (independent)
|
||
|
||
---
|
||
|
||
## Project Structure
|
||
|
||
```
|
||
account-tracking/
|
||
├── docker-compose.yml
|
||
├── .env # secrets (gitignored)
|
||
├── .env.example # template with placeholder values
|
||
├── src/
|
||
│ ├── AccountTracking.Api/ # .NET 8 Web API
|
||
│ │ ├── Controllers/
|
||
│ │ ├── Services/
|
||
│ │ ├── Models/
|
||
│ │ ├── Data/ # EF Core DbContext + migrations
|
||
│ │ └── Dockerfile
|
||
│ └── AccountTracking.Web/ # Vue 3 + Vuetify frontend
|
||
│ ├── src/
|
||
│ │ ├── api/ # NSwag-generated client (do not edit manually)
|
||
│ │ ├── stores/ # Pinia: auth, period
|
||
│ │ ├── views/ # Login, Dashboard, Transactions
|
||
│ │ └── components/ # YearSummary, MonthSummary, DonutChart,
|
||
│ │ # MonthlyBalancesChart, CumulativeSpendingChart
|
||
│ ├── nswag.json # NSwag config (input: openapi.json, output: src/api/)
|
||
│ ├── vite.config.ts # includes /api proxy to http://localhost:5000
|
||
│ └── Dockerfile
|
||
└── docs/
|
||
└── superpowers/specs/
|
||
```
|
||
|
||
---
|
||
|
||
## Key Decisions
|
||
|
||
- **Single user from config** — no user table; credentials in `.env`; password stored as bcrypt hash.
|
||
- **JWT lifetime 30 days** — long-lived for convenience; Base64-encoded 32-byte secret; expiry in ISO 8601 UTC; checked locally in router guard; redirect on expiry or 401.
|
||
- **Bank categories used as-is** — no re-categorisation UI needed.
|
||
- **Deduplication via `transaction_id`** — format `transhist-v-YYYY-MM_<sha>`; VARCHAR(512) utf8mb4_bin (case-sensitive) for correctness and future-proofing.
|
||
- **CSV header detection** — scan for line starting with `číslo účtu`; do not hardcode line number.
|
||
- **Spending totals exclude positive amounts per category** — refunds within a spending category are excluded from totals.
|
||
- **Dashboard columns share one period** — a single `{ year, month }` drives both columns; month toolbar wraps the year on overflow, year toolbar keeps the month.
|
||
- **Upload CSV in app bar** — file picker dialog in-place, no separate import page; result shown as snackbar.
|
||
- **Month summary line chart is cumulative spending** — running total of expenses day by day through the selected month; gaps filled so line is continuous.
|
||
- **Monthly balances bar chart** — closing balance per month for the year (sparse, months with no data omitted).
|
||
- **Search is contains-match (OR)** — `%term%` on counter_party_name OR message; case-insensitive via utf8mb4_unicode_ci.
|
||
- **Server-side search and pagination** — no client-side filtering of paginated data.
|
||
- **NSwag via MSBuild** — `openapi.json` generated at `dotnet build`; web Dockerfile copies it; no running API needed.
|
||
- **NSwag client base URL: `/api`** — relative; Vite dev proxy and Nginx prod proxy both resolve it correctly.
|
||
- **EF Core migrations at startup** — Polly retry: 5 attempts, exponential backoff; container exits on all-fail.
|
||
- **EF Core UTC datetime** — `DateTimeKind.Utc` on all datetime model properties (Pomelo requirement).
|
||
- **`bank_note` is read-only** — maps to ČSOB's "vlastní poznámka"; no user-edit UI.
|
||
- **ApexCharts** — Vue 3 compatible, dark theme compatible with Vuetify.
|