account-tracking/docs/superpowers/specs/2026-03-19-finance-tracker-design.md
Martin 74f924c715 Add finance tracker design spec and update .gitignore
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 22:21:08 +01:00

14 KiB
Raw Blame History

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:

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 proxyvite.config.ts proxies /apihttp://localhost:5000 with no path rewriting (since the API mounts all routes under /api/...):

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 12 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 (required)
      Returns 200: { totalSpent, totalIncome, currentBalance }
      — totalSpent: absolute value of sum of negative amounts WHERE booking_date
                    falls within the given year+month
      — totalIncome: sum of positive amounts within the given year+month
      — currentBalance: balance from the transaction with the globally latest
                        booking_date (highest id as tiebreaker), ignoring year/month
                        filter; null if no transactions exist at all

GET   /api/dashboard/spending-by-category
      Query: year (required), month (required)
      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/balance-trend
      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; ignores any month selection

Frontend

Pages

Page Route Auth Required
Login /login No
Dashboard / Yes
Transactions /transactions Yes
Import /import 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

Three KPI cards across the top (data from /api/dashboard/summary):

  • Total Spent — absolute value of negative amounts for selected month/year
  • Total Income — sum of positive amounts for selected month/year
  • Current Balance — globally most recent balance (not period-filtered); shows if null

Cards show a skeleton loader while fetching and an error state (red border, retry button) on failure.

Two charts below, side by side:

  • Spending by Category (ApexCharts horizontal bar) — category totals for selected month/year
  • Balance Trend (ApexCharts line) — closing balance per month for selected year; chart title includes year (e.g. "Balance trend — 2025") to communicate it is not filtered by month

Period picker (month + year dropdowns) in the app bar updates KPI cards and spending chart. Balance trend updates only when the year changes.

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)

Import Page

File input (.csv only), upload button. On success: "Imported X transactions, skipped Y duplicates." On error: shows the error string from the API response.

State Management (Pinia)

  • auth — token, expiresAt, login/logout actions, 401 handler
  • period — selected year/month, shared across dashboard and transactions

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, Import
│       │   └── components/       # KpiCard, SpendingChart, BalanceTrendChart
│       ├── 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.
  • currentBalance is not period-filtered — always the globally latest transaction balance; clearly documented in API spec and shown as "not filtered" in UI.
  • Balance trend omits months with no transactions — ApexCharts handles sparse data; no zero-fill.
  • 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 MSBuildopenapi.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 datetimeDateTimeKind.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.