diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b08c262 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +# Ignore everything +* + +# Except the pre-built artifacts +!backend/target/universal/stage/ +!frontend/dist/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2d271c8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,51 @@ +name: CI + +on: + push: + branches: [master, main] + pull_request: + branches: [master, main] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: 'sbt' + + - name: Set up sbt + uses: sbt/setup-sbt@v1 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Compile + run: sbt compile + + - name: Run backend tests + run: sbt backend/test + + - name: Build frontend (Scala.js) + run: sbt frontend/fastLinkJS + + - name: Install frontend dependencies + working-directory: frontend + run: npm ci + + - name: Build frontend (Vite) + working-directory: frontend + run: npm run build + + - name: Format check + run: sbt scalafmtCheckAll diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e63ed8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,54 @@ +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### IntelliJ IDEA ### +.idea/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store + +### Scala ### +.bsp/ + +### Node.js ### +node_modules/ + +### SSBudget ### +data/ +bugs.md +spec.md + +frontend/dist \ No newline at end of file diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..97b1dde --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,12 @@ +version = 3.10.3 +runner.dialect = scala3 +runner.dialectOverride.allowSignificantIndentation = false + +align.preset = most +trailingCommas = always +maxColumn = 150 +rewrite.rules = [AsciiSortImports, SortModifiers] +assumeStandardLibraryStripMargin = true + + +rewrite.scala3.convertToNewSyntax = true diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..24fa428 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,321 @@ +# SSBudget - Claude Context File + +## Project Overview + +Personal budget tracker for tracking monthly expenses, bank balances, and calculating available spending money. Internet-facing with passkey authentication (no user management). + +## UI Design Principles + +**Spreadsheet-like efficiency** - The UI should feel like a well-designed spreadsheet: +- **Concise**: Maximum information density, minimal chrome +- **Direct manipulation**: Edit in place, no unnecessary modals or multi-step wizards. But explicit submission is ok when needed +- **Minimal clicks**: Common actions (update balance, mark paid) should be 1-2 clicks +- **Scannable**: Numbers aligned, status visible at a glance + +Think "Google Sheets for personal budget" not "enterprise dashboard with cards everywhere". + +## Core Concepts + +### Period +- Starts when paycheck arrives (~25th of month, flexible) +- Ends when next paycheck arrives +- All calculations are relative to current period + +### Expense Types + +1. **Planned Expenses** - Fixed monthly bills (rent, subscriptions, etc.) + - Have an estimated amount + - Get marked as "paid" with actual amount + - Unpaid ones contribute their estimate to predicted expenses + - Estimate can be: fixed value, last month's actual, or historical average + +2. **Estimated Expenses** - Variable ongoing costs (groceries, fuel, etc.) + - Have a monthly estimate + - Never explicitly "paid" - consumed implicitly over time + - Scale with remaining period (10 days left = 1/3 of monthly estimate) + - Can toggle whether included in remaining balance calculation + +### Savings + +**Savings Accounts** - Buckets for accumulating money (emergency fund, vacation, etc.) +- Have a currency and current balance +- Balance is editable directly (for corrections/initial setup) +- Can have an optional monthly target (planned savings amount) +- Transactions (inflows/outflows) modify the balance + +**Savings Transactions** - Individual money movements +- Positive = inflow (contributing to savings) +- Negative = outflow (withdrawing from savings) +- Can have multiple per period (e.g., +500, -100, -200) +- Optional note for context + +### Key Calculation +``` +Free Money = Total Balance - Predicted Expenses - Remaining Savings +Daily Budget = Free Money / Days Until Period End +``` + +Where: +- `Predicted Expenses = Sum(unpaid planned estimates) + Scaled(estimated expenses)` +- `Remaining Savings = Sum(plannedMonthly - period contributions) for accounts with targets` +- Period contributions = sum of transactions for current period per savings account + +## Tech Stack + +| Layer | Technology | +|-------------|------------------------------------------| +| Language | Scala 3.5.2 | +| Backend | cats-effect, tapir, http4s | +| Frontend | Laminar (Scala.js SPA) | +| API | tapir (shared endpoint definitions) | +| Database | SQLite + Flyway migrations | +| JSON | circe | +| CSS | Bootstrap 5 (CSS-only) | +| Bundler | Vite + vite-plugin-scalajs | +| Auth | Passkeys (WebAuthn) via java-webauthn-server | +| Deployment | Docker + fly.io | + +## Reference Projects + +- **workflow4s-web-ui** (`/Users/krever/Projects/priv/workflow4s/workflows4s-web-ui`) - Reference for Vite + Scala.js setup +- **laminar-full-stack-demo** (https://github.com/raquo/laminar-full-stack-demo) - Reference for Laminar full-stack architecture +- **forms4s** (`/Users/krever/Projects/priv/forms4s`) - Form/datatable library to extend with Laminar support +- **business4s ecosystem** (https://business4s.org/) - Parent OSS ecosystem + +### forms4s Integration Strategy +1. Use `forms4s-core` for table/form state management (no UI dependency) +2. Build `forms4s-laminar` module as part of this project (can be extracted later) +3. Leverage existing: TableDef, TableState, filtering, sorting, pagination, URL state encoding + +## Data Model (Conceptual) + +``` +ExpenseDefinition: + - id, name, type (planned|estimated) + - estimateMode (fixed|lastMonth|average) + - fixedEstimate (optional) + - includeInBalance (for estimated type) + +Period: + - id, startDate, endDate (nullable until closed) + +ExpenseRecord (for planned expenses): + - periodId, expenseDefId, paidAmount (nullable), paidDate + +BalanceSnapshot: + - accountId, amount, currency, timestamp + +Account: + - id, name, currency (PLN|EUR) + +SavingsAccount: + - id, name, currency (PLN|EUR) + - currentBalance (cents, editable directly) + - plannedMonthly (optional target per period) + +SavingsTransaction: + - id, accountId, periodId + - amount (positive = inflow/saving, negative = outflow/withdrawal) + - note (optional) + - createdAt + +ExchangeRate: + - fromCurrency, toCurrency, rate, fetchedAt + +PasskeyCredential: + - credentialId, publicKey, signCount, createdAt +``` + +## Authentication + +**Passkeys (WebAuthn)** - Modern passwordless authentication +- No user accounts - just credential registration +- Library: [Yubico java-webauthn-server](https://github.com/Yubico/java-webauthn-server) +- Frontend uses Web Authentication API (browser native) +- Credentials stored in SQLite +- First visitor registers a passkey, subsequent access requires registered passkey + +Implementation resources: +- https://developers.yubico.com/java-webauthn-server/ +- https://github.com/YubicoLabs/passkey-workshop + +## Notifications + +- MVP: "Copy to clipboard" button for summary +- Target: WhatsApp integration (via API or webhook) + +Summary format (example): +``` +Budget Update (Jan 15) +Balance: 5,000 PLN +Predicted: 2,500 PLN +Free: 2,500 PLN +Daily: 250 PLN (10 days left) +``` + +## File Structure (Target) + +``` +ssbudget/ +├── build.sbt # Multi-module build +├── project/ +│ ├── build.properties +│ └── plugins.sbt # ScalaJS, Flyway, native-packager +│ +├── shared/ # Cross-compiled (JVM + JS) +│ └── src/main/scala/ssbudget/shared/ +│ ├── api/ # Tapir endpoint definitions +│ └── model/ # Domain models (Expense, Account, etc.) +│ +├── backend/ +│ └── src/main/scala/ssbudget/backend/ +│ ├── Main.scala +│ ├── db/ # SQLite + Flyway + repositories +│ ├── auth/ # WebAuthn/passkey handling +│ └── service/ # Business logic +│ +├── frontend/ # Scala.js + Laminar +│ ├── vite.config.mjs +│ ├── package.json +│ ├── index.html +│ └── src/main/scala/ssbudget/frontend/ +│ ├── Main.scala # @JSExportTopLevel entry point +│ ├── api/ # HTTP client (tapir-sttp-client) +│ ├── components/ # Laminar components +│ └── pages/ # Page components +│ +├── forms4s-laminar/ # Laminar integration for forms4s +│ └── src/main/scala/ +│ +└── docker/ + └── Dockerfile +``` + +## Development Workflow + +Three-terminal setup for development: + +```bash +# Terminal 1: Scala.js continuous compilation +sbt '~frontend/fastLinkJS' + +# Terminal 2: Vite dev server (hot reload, proxies /api to backend) +cd frontend && npm install && npm run dev + +# Terminal 3: Backend server +sbt backend/run +``` + +Navigate to `http://localhost:3000` - Vite proxies API calls to backend. + +## Build Commands + +```bash +# Development +sbt '~frontend/fastLinkJS' # Watch mode for frontend +sbt backend/run # Run backend +cd frontend && npm run dev # Vite dev server + +# Production build +sbt frontend/fullLinkJS # Optimized JS +cd frontend && npm run build # Vite production bundle +sbt backend/assembly # Fat JAR with bundled frontend + +# Database +sbt backend/flywayMigrate # Run migrations + +# Docker +docker build -t ssbudget . +``` + +## Critical Build Settings + +```scala +// build.sbt - REQUIRED for Vite integration +scalaJSLinkerConfig ~= { _.withModuleKind(ModuleKind.ESModule) } +``` + +```javascript +// vite.config.mjs +import scalaJSPlugin from "@scala-js/vite-plugin-scalajs"; + +export default defineConfig({ + plugins: [ + scalaJSPlugin({ + cwd: "..", // Parent directory with build.sbt + projectID: "frontend" // Must match sbt project name + }) + ], + server: { + proxy: { '/api': 'http://localhost:8080' } + } +}) +``` + +## Session Workflow + +This project uses incremental development across multiple Claude sessions: +1. Check `ROADMAP.md` for current phase +2. Check `docs/sessions/` for completed work +3. Pick next item from roadmap +4. Create detailed plan for the session +5. Implement +6. Update session log and roadmap status + +## Code Style + +**MANDATORY: Always use curly braces syntax. Never use indentation-based syntax (Scala 3 braceless style).** + +**MANDATORY: Always run `sbt scalafmtAll` before finishing work to format all Scala code.** + +**circe codecs**: Use `derives Codec.AsObject` for case classes. Only use manual `Encoder`/`Decoder` for: +- AnyVal wrapper types (encode as the underlying type) +- Enums with custom string representations +- Types like `LocalDate`, `Instant` that need custom serialization + +## Key Decisions Log + +| Decision | Choice | Rationale | +|--------------------|---------------------------|--------------------------------------------------| +| Database | SQLite + Flyway | Simple, file-based, migrations built-in | +| CSS Framework | Bootstrap 5 | Industry standard, extensive components, good docs | +| Auth | Passkeys (WebAuthn) | Modern, passwordless, secure, no passwords to manage | +| Bundler | Vite + vite-plugin-scalajs| Fast dev, HMR, proven in workflow4s | +| Historical data | Per-update | Track each balance update with timestamp | +| Expense recurrence | Monthly only | Keep simple | +| HTTP client | tapir-sttp-client | Type-safe, shares endpoint defs with backend | +| Scala version | 3.5.2 | Scala 3.8.1 has Scala.js compiler bug (js.async) | +| UI philosophy | Spreadsheet-like | Concise, direct edit, minimal clicks | +| Savings accounts | Separate entity | Different behavior from bank accounts (editable balance, targets) | + +## Laminar/Airstream Gotchas + +**Signal combination**: When combining 3+ signals, use chained `combineWith` instead of `Signal.combine`: + +```scala +// DON'T - Signal.combine with 3+ signals fails silently (pattern match doesn't work) +Signal.combine(sig1, sig2, sig3, sig4).map { case (a, b, c, d) => ... } + +// DO - Use chained combineWith (tuplez library flattens to flat tuple) +sig1.combineWith(sig2).combineWith(sig3).combineWith(sig4).map { case (a, b, c, d) => ... } +``` + +Note: `Signal.combine` with exactly 2 signals works fine. + +**ZoneId.systemDefault() in Scala.js**: `ZoneId.systemDefault()` fails silently without the `scala-java-time-tzdb` dependency. If an object has `ZoneId.systemDefault()` in its static initialization, any call to that object (even unrelated methods) will fail silently. + +```scala +// DON'T - causes entire object to fail in Scala.js +object Formatting { + private val zone = ZoneId.systemDefault() // This breaks everything! + def formatMoney(cents: Long, currency: Currency): String = ... +} + +// DO - use fixed timezone +object Formatting { + private val zone = ZoneId.of("UTC") // This works + def formatMoney(cents: Long, currency: Currency): String = ... +} +``` + +If you need system timezone support, add `scala-java-time-tzdb` to your dependencies. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..669e81f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM eclipse-temurin:21-jre + +RUN apt-get update && apt-get install -y --no-install-recommends sqlite3 && rm -rf /var/lib/apt/lists/* +RUN mkdir -p /data + +WORKDIR /opt/docker + +# Copy pre-built backend and frontend (run ./build.sh first) +COPY backend/target/universal/stage/ ./ +COPY frontend/dist/ ./static/ + +ENV SSBUDGET_PORT=8080 +ENV SSBUDGET_DB_PATH=/data/ssbudget.db +ENV SSBUDGET_STATIC_DIR=/opt/docker/static + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -q --spider http://localhost:8080/api/health || exit 1 + +ENTRYPOINT ["bin/ssbudget"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..311c50e --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ +# SSBudget + +A personal budget tracker built to answer one question: **"How much can I spend this month?"** + +## The Problem + +Traditional budget apps obsess over categories and past spending. SSBudget focuses on the future. + +SSBudget takes a different approach: +- Define your **fixed expenses** (rent, bills), **savings goals**, and **estimated variable costs** (groceries, fuel) +- Update account balances and mark previously planned payments throughout the period +- Get your remaining free cash — your budget until the next paycheck + +## Additional Features + +- **Multi-currency** — PLN, EUR, USD + 30 more with live exchange rates +- **Passkey auth** — Passwordless login, no user management needed +- **Spreadsheet-like UI** — Edit in place, minimal clicks, maximum density +- **Self-hosted** — Your data stays on your SQLite file + +## Built With Claude + +This project was 100% vibe-coded using [Claude Code](https://www.anthropic.com/claude-code) over 8 sessions (~4 days). +The entire codebase — backend, frontend, database schema, tests, deployment config — was generated through conversation +with Claude Opus/Sonnet. + +Check `docs/sessions/` for the session logs and `CLAUDE.md` for the context file that guided development. + +## Tech Stack + +| Layer | Stack | +|----------|-------------------------------------| +| Language | Scala 3 (JVM + Scala.js) | +| Backend | cats-effect, http4s, tapir, doobie | +| Frontend | Laminar (Scala.js SPA), Bootstrap 5 | +| Database | SQLite + Flyway migrations | +| Auth | WebAuthn passkeys + Argon2 password | +| Build | sbt, Vite | + +## Quick Start + +### Prerequisites + +- JDK 21+ +- sbt 1.9+ +- Node.js 18+ + +### Development (3 terminals) + +```bash +# Terminal 1: Scala.js watch +sbt '~frontend/fastLinkJS' + +# Terminal 2: Vite dev server +cd frontend && npm install && npm run dev + +# Terminal 3: Backend +sbt backend/run +``` + +Open http://localhost:3000. First visit prompts password setup. + +### Production Build + +```bash +./build.sh # Builds backend + frontend + Docker image +docker run -p 8080:8080 -v ./data:/data ssbudget +``` + +### Environment Variables + +| Variable | Default | Description | +|-----------------------|-----------------------------------------------|---------------------------| +| `SSBUDGET_DB_PATH` | `data/ssbudget.db` | SQLite database path | +| `SSBUDGET_PORT` | `8080` | Server port | +| `SSBUDGET_RP_ID` | `localhost` | WebAuthn relying party ID | +| `SSBUDGET_RP_ORIGINS` | `http://localhost:3000,http://localhost:8080` | Allowed origins | + +## Deployment + +See `fly.toml` for a fly.io deployment example. Key points: + +- Persistent volume for SQLite +- Secrets for `SSBUDGET_RP_ID` and `SSBUDGET_RP_ORIGINS` + +## Project Structure + +``` +ssbudget/ +├── shared/ # Cross-compiled models and API definitions +├── backend/ # http4s server, repositories, auth +├── frontend/ # Laminar SPA +├── e2e/ # Selenium tests +└── docs/ # Session logs +``` \ No newline at end of file diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..0ca5e1e --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,312 @@ +# SSBudget Roadmap + +## Overview + +Development is split into phases. Each phase should result in a usable increment. Sessions pick items from the current phase and implement them fully. + +**UI-Driven Approach**: Since this is a single-purpose personal app, the UI drives API design. We build screens first with mock data, then implement exactly the API endpoints and business logic each screen needs. This avoids over-engineering and ensures the backend serves the frontend's actual requirements. + +--- + +## Phase 1: Foundation & Skeleton +**Goal**: Working cross-build with backend serving static frontend, Vite dev setup. + +- [x] **1.1 Multi-Module SBT Build** + - Three projects: `shared`, `backend`, `frontend` + - Cross-compilation setup for shared code + - ES Module output for Scala.js (required for Vite) + - Dependencies: cats-effect, http4s, tapir, circe, Laminar + +- [x] **1.2 Vite + Scala.js Integration** + - `frontend/vite.config.mjs` with vite-plugin-scalajs + - `frontend/package.json` with Bootstrap, Vite deps + - `frontend/index.html` entry point + - Proxy `/api` to backend in dev mode + +- [x] **1.3 Basic Backend** + - http4s server with tapir + - Health check endpoint (`GET /api/health`) + - Static file serving for production (bundled frontend) + - Configuration via environment variables + +- [x] **1.4 Basic Frontend** + - Laminar app shell with `@JSExportTopLevel` + - Bootstrap CSS integration + - Simple page showing "Hello" + health check result + - Verify hot reload works + +--- + +## Phase 2: Data Layer +**Goal**: SQLite database with migrations, core domain models. + +- [x] **2.1 Database Setup** + - SQLite integration (doobie or skunk) + - Flyway migrations plugin + - Connection management with cats-effect Resource + +- [x] **2.2 Core Schema (Migrations)** + - `V1__initial_schema.sql` - all tables in single migration + - accounts, expense_definitions, periods, expense_records, balance_snapshots, exchange_rates + +- [x] **2.3 Repository Layer** + - Type-safe queries with doobie + - Repository traits and implementations in backend + - CRUD for all entities + specialized queries + +--- + +## Phase 3: Frontend - Core UI +**Goal**: Build UI screens with mock data. Let the UI drive API requirements. + +*Strategy*: Each screen starts with hardcoded/mock data. As screens mature, we identify exactly what API calls and business logic they need. This ensures we only build backend functionality that the UI actually requires. + +- [x] **3.1 Layout & Navigation** + - App shell with Bootstrap navbar + - Dashboard page (placeholder) + - Expenses page (placeholder) + - Accounts page (placeholder) + - Client-side routing (Waypoint or manual) + +- [x] **3.2 Dashboard** + - Current balance display (big number) + - Free money / daily budget + - Days remaining in period + - Quick actions (update balance, start period) + - *Mock*: hardcoded summary data + +- [x] **3.3 Expense Management** + - List expense definitions (planned + estimated) + - Add/edit expense definition modal + - Mark expense as paid (for current period) + - Toggle estimated expense inclusion + - *Mock*: hardcoded expense list + +- [x] **3.4 Account Management** + - List accounts with latest balance + - Add/edit account + - Record new balance snapshot + - *Mock*: hardcoded account list + +- [x] **3.5 Period Management** + - Current period info + - "Start new period" button + - Period history list + - *Mock*: hardcoded period data + +--- + +## Phase 3.5: Savings Support +**Goal**: Add savings accounts with targets and transaction tracking. + +*Savings accounts* are separate from regular bank accounts - they represent buckets for accumulating money (emergency fund, vacation, etc.). They have editable balances and optional monthly targets. Transactions track inflows/outflows. + +- [x] **3.5.1 Savings Data Layer** + - Schema migration: savings_accounts, savings_transactions tables + - Scala models: SavingsAccount, SavingsTransaction + - Repository: SavingsAccountRepository, SavingsTransactionRepository + - Tests for repositories + +- [x] **3.5.2 Savings UI (Mock Data)** + - Savings section on Accounts page + - List savings accounts with balance, target, period progress + - Add/edit savings account (name, currency, target) + - Edit balance directly (for corrections) + - Add transaction (+/-) with optional note + - Show transactions for current period + +--- + +## Phase 4: API & Business Logic +**Goal**: Implement API endpoints and calculations driven by UI needs. + +*Strategy*: For each UI screen, define the tapir endpoints it needs, implement backend handlers, and wire up the frontend. Business logic (calculations, period management) is implemented as needed to support the API. + +- [x] **4.1 Frontend HTTP Client Setup** + - tapir-sttp-client integration + - API service layer pattern + - Error handling utilities + +- [x] **4.2 Account & Balance API** + - Account CRUD endpoints + - Balance snapshot recording + - Sum balances across accounts (with EUR conversion) + - Wire to Account Management UI + +- [x] **4.3 Expense API** + - Expense definition CRUD endpoints + - Expense payment recording + - Expense prediction calculations (unpaid planned + scaled estimated) + - Wire to Expense Management UI + +- [x] **4.4 Period API** + - Period management endpoints (start, current, list) + - Period state transitions + - Wire to Period Management UI + +- [x] **4.5 Dashboard Summary API** + - Budget summary endpoint + - Free money calculation (including remaining savings) + - Daily budget calculation + - Wire to Dashboard UI + +--- + +## Phase 5: Authentication (Password + Passkeys) +**Goal**: Password and WebAuthn passkey authentication protecting all routes. + +- [x] **5.1 Backend Auth Setup** + - Add java-webauthn-server + argon2-jvm dependencies + - Credential storage schema (auth_config, sessions, passkey_credentials) + - RelyingParty configuration via environment variables + +- [x] **5.2 Registration Flow** + - `/api/auth/setup` - initial password setup (auto-login) + - `/api/auth/passkey/register/start` - generate WebAuthn challenge + - `/api/auth/passkey/register/finish` - verify and store credential + +- [x] **5.3 Authentication Flow** + - `/api/auth/login` - password login + - `/api/auth/passkey/login/start` - generate WebAuthn challenge + - `/api/auth/passkey/login/finish` - verify credential + - Session token generation (HttpOnly cookies, 30-day expiry) + +- [x] **5.4 Frontend Auth Integration** + - WebAuthn browser API wrapper (WebAuthnFacade) + - SetupPage - first-time password setup + - LoginPage - password + optional passkey login + - SettingsPage - passkey management + - Auth state management (AuthState) + +- [x] **5.5 Middleware & Session** + - Session validation on all data endpoints via Tapir serverSecurityLogic + - HttpOnly session cookies (configurable secure flag) + - Logout endpoint with session invalidation + +--- + +## Phase 6: Notifications & Summary +**Goal**: Summary sharing functionality. + +- [x] **6.1 Summary Formatting** + - Text format for clipboard/messaging + - Configurable template (optional) + +- [x] **6.2 Copy to Clipboard** + - Button on dashboard + - Visual feedback (toast/notification) + +- [ ] **6.3 WhatsApp Integration** + - Research: WhatsApp Business API vs Twilio vs wa.me links + - Implement chosen approach + - Recipient configuration in settings + +--- + +## Phase 7: forms4s-laminar Integration +**Goal**: Build Laminar renderer for forms4s, refactor app to use it. + +- [ ] **7.1 Laminar Module Setup** + - `forms4s-laminar` submodule + - Dependency on forms4s-core + +- [ ] **7.2 Form Renderer** + - FormRenderer trait for Laminar + - Basic elements: text, number, select, checkbox + - Bootstrap styling + - Validation display + +- [ ] **7.3 Table Renderer** + - TableRenderer trait for Laminar + - Column rendering + - Filtering UI + - Sorting UI + - Pagination + +- [ ] **7.4 Refactor App** + - Replace manual forms with forms4s + - Replace manual tables with forms4s datatables + - Extract reusable patterns + +--- + +## Phase 8: Polish & Extras +**Goal**: Quality of life improvements. + +- [x] **8.1 Exchange Rate API & Multi-Currency Support** + - Changed Currency from enum to value class (32 ISO 4217 codes) + - CurrencySetting model: enable/disable currencies, set primary + - Integrated Frankfurter API (https://api.frankfurter.dev) + - Manual refresh button with last updated time + - Searchable dropdown for adding currencies + - Currency validation against known codes + +- [ ] **8.2 Historical Data** + - View expense history per definition + - Average calculations display + - Import from CSV/JSON (low priority) + +- [ ] **8.3 Mobile Optimization** + - Responsive design review + - Touch-friendly controls + - PWA manifest (optional) + +- [x] **8.4 Data Backup/Restore** + - Database export (download SQLite file) + - Database import (upload SQLite file, live restore via SQLite backup API) + - E2E tests for Settings page Data card + +--- + +## Phase 9: Production Hardening +**Goal**: Ready for daily use. + +- [ ] **9.1 Docker & Deployment** + - Multi-stage Dockerfile + - fly.io configuration (fly.toml) + - Environment variable handling + - SQLite volume persistence + +- [ ] **9.2 Error Handling** + - Graceful error display in UI + - Retry logic for network errors + - Offline indicator + +- [ ] **9.3 Logging & Monitoring** + - Structured logging (log4cats) + - Health checks for fly.io + - Basic metrics (optional) + +- [ ] **9.4 Security Review** + - HTTPS enforcement + - CORS configuration + - Input validation audit + - Rate limiting (optional) + +--- + +## Future Ideas (Not Planned) + +- Expense forecasting +- Mobile native app (or PWA) +- Multi-user with proper accounts +- Bill due date reminders +- Receipt photo storage +- Bank API integration (open banking) + +--- + +## Session Log + +| Session | Date | Phase | Items Completed | Notes | +|---------|------------|-------|------------------|----------------------------------------| +| 0 | 2026-01-26 | - | Initial planning | Created CLAUDE.md, ROADMAP.md, spec.md | +| 1 | 2026-01-26 | 1 | 1.1, 1.2, 1.3, 1.4 | Foundation complete, Scala 3.5.2 due to JS bug | +| 2 | 2026-01-27 | 2 | 2.1, 2.2, 2.3 | Data layer complete with doobie, flyway, scalatest, 34 tests | +| 3 | 2026-01-27 | 3 | 3.1-3.5, 6.1-6.2 | Frontend UI complete with mock data, e2e tests, copy summary | +| 4 | 2026-01-27 | 3.5 | 3.5.1, 3.5.2 | Savings support: data layer + UI, 50 backend tests, 33 e2e tests | +| 5 | 2026-01-28 | 4 | 4.1-4.5 | API integration, e2e infrastructure with auto-managed servers | +| 6 | 2026-01-28 | 5 | 5.1-5.5 | Password + passkey auth, 50 backend + 70 e2e tests | +| 7 | 2026-01-29 | 8 | 8.1 | Multi-currency support, 32 currencies, Frankfurter API, 86 e2e tests | +| 8 | 2026-01-29 | 8 | 8.4 | Database backup/restore via SQLite backup API, 3 functional e2e tests | + diff --git a/backend/src/main/resources/db/migration/V1__initial_schema.sql b/backend/src/main/resources/db/migration/V1__initial_schema.sql new file mode 100644 index 0000000..65f2563 --- /dev/null +++ b/backend/src/main/resources/db/migration/V1__initial_schema.sql @@ -0,0 +1,81 @@ +-- SSBudget Initial Schema +-- Money stored as INTEGER (cents), timestamps as TEXT (ISO 8601) + +-- Accounts (bank accounts) +CREATE TABLE accounts ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + currency TEXT NOT NULL CHECK (currency IN ('PLN', 'EUR')) +); + +-- Budget item definitions (planned expenses, estimated expenses, planned incomes) +CREATE TABLE expense_definitions ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + item_type TEXT NOT NULL CHECK (item_type IN ('planned_expense', 'estimated_expense', 'planned_income')), + estimate_mode TEXT NOT NULL CHECK (estimate_mode IN ('fixed', 'last_month', 'average')), + fixed_estimate INTEGER -- in cents, nullable (only for fixed mode) +); + +-- Periods (budget periods, typically monthly) +CREATE TABLE periods ( + id TEXT PRIMARY KEY, + started_at TEXT NOT NULL, -- ISO 8601 timestamp + ended_at TEXT -- nullable until closed +); + +-- Expense records (actual payments for planned expenses) +CREATE TABLE expense_records ( + id TEXT PRIMARY KEY, + period_id TEXT NOT NULL REFERENCES periods(id), + expense_def_id TEXT NOT NULL REFERENCES expense_definitions(id), + paid_amount INTEGER, -- in cents, nullable until paid + paid_at TEXT, -- ISO 8601 timestamp, nullable until paid + UNIQUE (period_id, expense_def_id) +); + +-- Balance snapshots (point-in-time account balances) +CREATE TABLE balance_snapshots ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL REFERENCES accounts(id), + amount INTEGER NOT NULL, -- in cents + currency TEXT NOT NULL CHECK (currency IN ('PLN', 'EUR')), + recorded_at TEXT NOT NULL -- ISO 8601 timestamp +); + +-- Exchange rates (no id, uses natural key) +CREATE TABLE exchange_rates ( + from_currency TEXT NOT NULL CHECK (from_currency IN ('PLN', 'EUR')), + to_currency TEXT NOT NULL CHECK (to_currency IN ('PLN', 'EUR')), + rate INTEGER NOT NULL, -- rate * 10000 for precision + fetched_at TEXT NOT NULL -- ISO 8601 timestamp +); + +-- Savings accounts (separate from bank accounts - editable balance, optional targets) +CREATE TABLE savings_accounts ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + currency TEXT NOT NULL CHECK (currency IN ('PLN', 'EUR')), + current_balance INTEGER NOT NULL DEFAULT 0, -- in cents, editable directly + planned_monthly INTEGER -- optional monthly target in cents +); + +-- Savings transactions (inflows/outflows to savings accounts) +CREATE TABLE savings_transactions ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL REFERENCES savings_accounts(id), + period_id TEXT NOT NULL REFERENCES periods(id), + amount INTEGER NOT NULL, -- positive = inflow, negative = outflow + note TEXT, -- optional context + created_at TEXT NOT NULL -- ISO 8601 timestamp +); + +-- Indexes for common queries +CREATE INDEX idx_expense_records_period ON expense_records(period_id); +CREATE INDEX idx_expense_records_def ON expense_records(expense_def_id); +CREATE INDEX idx_balance_snapshots_account ON balance_snapshots(account_id); +CREATE INDEX idx_balance_snapshots_recorded ON balance_snapshots(recorded_at); +CREATE INDEX idx_exchange_rates_currencies ON exchange_rates(from_currency, to_currency); +CREATE INDEX idx_exchange_rates_fetched ON exchange_rates(fetched_at); +CREATE INDEX idx_savings_transactions_account ON savings_transactions(account_id); +CREATE INDEX idx_savings_transactions_period ON savings_transactions(period_id); diff --git a/backend/src/main/resources/db/migration/V2__auth_schema.sql b/backend/src/main/resources/db/migration/V2__auth_schema.sql new file mode 100644 index 0000000..0814769 --- /dev/null +++ b/backend/src/main/resources/db/migration/V2__auth_schema.sql @@ -0,0 +1,29 @@ +-- Authentication schema + +-- Auth config (singleton table for password) +CREATE TABLE auth_config ( + id INTEGER PRIMARY KEY CHECK (id = 1), + password_hash TEXT, -- Argon2 hash + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- Sessions (HttpOnly cookie sessions) +CREATE TABLE sessions ( + token TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + last_used_at TEXT NOT NULL +); + +-- Passkey credentials (WebAuthn) +CREATE TABLE passkey_credentials ( + credential_id TEXT PRIMARY KEY, + public_key_cose BLOB NOT NULL, + sign_count INTEGER NOT NULL DEFAULT 0, + display_name TEXT, + created_at TEXT NOT NULL, + last_used_at TEXT +); + +CREATE INDEX idx_sessions_expires ON sessions(expires_at); diff --git a/backend/src/main/resources/db/migration/V3__currency_settings.sql b/backend/src/main/resources/db/migration/V3__currency_settings.sql new file mode 100644 index 0000000..381b5ea --- /dev/null +++ b/backend/src/main/resources/db/migration/V3__currency_settings.sql @@ -0,0 +1,71 @@ +-- Currency settings migration +-- Move from hardcoded PLN/EUR enum to configurable currency settings + +-- Create currency_settings table +CREATE TABLE currency_settings ( + code TEXT PRIMARY KEY, + name TEXT NOT NULL, + is_primary INTEGER NOT NULL DEFAULT 0, + enabled_at TEXT NOT NULL +); + +-- Ensure only one primary currency (SQLite partial unique index) +CREATE UNIQUE INDEX idx_currency_primary ON currency_settings(is_primary) WHERE is_primary = 1; + +-- Seed initial data with PLN as primary (using ISO 8601 format for Java Instant parsing) +INSERT INTO currency_settings (code, name, is_primary, enabled_at) VALUES ('PLN', 'Polish Zloty', 1, strftime('%Y-%m-%dT%H:%M:%SZ', 'now')); +INSERT INTO currency_settings (code, name, is_primary, enabled_at) VALUES ('EUR', 'Euro', 0, strftime('%Y-%m-%dT%H:%M:%SZ', 'now')); + +-- SQLite does not support ALTER TABLE DROP CONSTRAINT, so we need to recreate tables +-- to remove the CHECK constraints. For existing data, we'll preserve it. + +-- Recreate accounts table without CHECK constraint +CREATE TABLE accounts_new ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + currency TEXT NOT NULL REFERENCES currency_settings(code) +); +INSERT INTO accounts_new SELECT id, name, currency FROM accounts; +DROP TABLE accounts; +ALTER TABLE accounts_new RENAME TO accounts; + +-- Recreate balance_snapshots table without CHECK constraint +CREATE TABLE balance_snapshots_new ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL REFERENCES accounts(id), + amount INTEGER NOT NULL, + currency TEXT NOT NULL REFERENCES currency_settings(code), + recorded_at TEXT NOT NULL +); +INSERT INTO balance_snapshots_new SELECT id, account_id, amount, currency, recorded_at FROM balance_snapshots; +DROP TABLE balance_snapshots; +ALTER TABLE balance_snapshots_new RENAME TO balance_snapshots; + +-- Recreate exchange_rates table without CHECK constraint +CREATE TABLE exchange_rates_new ( + from_currency TEXT NOT NULL REFERENCES currency_settings(code), + to_currency TEXT NOT NULL REFERENCES currency_settings(code), + rate INTEGER NOT NULL, + fetched_at TEXT NOT NULL +); +INSERT INTO exchange_rates_new SELECT from_currency, to_currency, rate, fetched_at FROM exchange_rates; +DROP TABLE exchange_rates; +ALTER TABLE exchange_rates_new RENAME TO exchange_rates; + +-- Recreate savings_accounts table without CHECK constraint +CREATE TABLE savings_accounts_new ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + currency TEXT NOT NULL REFERENCES currency_settings(code), + current_balance INTEGER NOT NULL DEFAULT 0, + planned_monthly INTEGER +); +INSERT INTO savings_accounts_new SELECT id, name, currency, current_balance, planned_monthly FROM savings_accounts; +DROP TABLE savings_accounts; +ALTER TABLE savings_accounts_new RENAME TO savings_accounts; + +-- Recreate indexes that were dropped +CREATE INDEX idx_balance_snapshots_account ON balance_snapshots(account_id); +CREATE INDEX idx_balance_snapshots_recorded ON balance_snapshots(recorded_at); +CREATE INDEX idx_exchange_rates_currencies ON exchange_rates(from_currency, to_currency); +CREATE INDEX idx_exchange_rates_fetched ON exchange_rates(fetched_at); diff --git a/backend/src/main/resources/db/migration/V4__budget_item_currency.sql b/backend/src/main/resources/db/migration/V4__budget_item_currency.sql new file mode 100644 index 0000000..51935c6 --- /dev/null +++ b/backend/src/main/resources/db/migration/V4__budget_item_currency.sql @@ -0,0 +1,3 @@ +-- Add currency to budget items (planned expenses, incomes, estimated expenses) +-- Default to PLN for existing rows, then make NOT NULL +ALTER TABLE expense_definitions ADD COLUMN currency TEXT NOT NULL DEFAULT 'PLN'; diff --git a/backend/src/main/scala/ssbudget/backend/AuthRoutes.scala b/backend/src/main/scala/ssbudget/backend/AuthRoutes.scala new file mode 100644 index 0000000..f2d80bc --- /dev/null +++ b/backend/src/main/scala/ssbudget/backend/AuthRoutes.scala @@ -0,0 +1,284 @@ +package ssbudget.backend + +import cats.effect.IO +import cats.implicits.* +import org.http4s.HttpRoutes +import sttp.model.headers.CookieValueWithMeta +import sttp.tapir.server.http4s.Http4sServerInterpreter +import ssbudget.backend.auth.{PasswordService, SessionService, WebAuthnService} +import ssbudget.backend.db.repository.{AuthConfigRepository, PasskeyCredentialRepository} +import ssbudget.shared.api.* + +import java.time.Duration as JDuration + +object AuthRoutes { + + // Cookie configuration + private val cookieMaxAge = JDuration.ofDays(30).toSeconds + private val cookieSecure = sys.env.get("SSBUDGET_COOKIE_SECURE").exists(_.toLowerCase == "true") + + private def sessionCookie(token: String): CookieValueWithMeta = + CookieValueWithMeta.unsafeApply( + value = token, + expires = None, + maxAge = Some(cookieMaxAge), + domain = None, + path = Some("/"), + secure = cookieSecure, + httpOnly = true, + sameSite = None, + otherDirectives = Map.empty, + ) + + private def clearCookie: CookieValueWithMeta = + CookieValueWithMeta.unsafeApply( + value = "", + expires = None, + maxAge = Some(0), + domain = None, + path = Some("/"), + secure = cookieSecure, + httpOnly = true, + sameSite = None, + otherDirectives = Map.empty, + ) + + def make( + authConfigRepo: AuthConfigRepository, + passkeyRepo: PasskeyCredentialRepository, + passwordService: PasswordService, + sessionService: SessionService, + webAuthnService: WebAuthnService, + testMode: Boolean = false, + ): HttpRoutes[IO] = { + val interpreter = Http4sServerInterpreter[IO]() + + // Status endpoint - uses optional session cookie to check login state + val statusRoute = interpreter.toRoutes( + AuthEndpoints.status + .serverSecurityLogic(tokenOpt => checkLoginStatus(sessionService, tokenOpt, testMode)) + .serverLogic(loggedIn => _ => getAuthStatus(authConfigRepo, passkeyRepo, loggedIn, testMode)), + ) + + // Setup endpoint - creates password and returns session cookie + val setupRoute = interpreter.toRoutes( + AuthEndpoints.setup.serverLogic(req => setupPassword(authConfigRepo, passwordService, sessionService, req)), + ) + + // Login endpoint - validates password and returns session cookie + val loginRoute = interpreter.toRoutes( + AuthEndpoints.login.serverLogic(req => login(authConfigRepo, passwordService, sessionService, req)), + ) + + // Logout endpoint - invalidates session and clears cookie + val logoutRoute = interpreter.toRoutes( + AuthEndpoints.logout + .serverSecurityLogic(tokenOpt => IO.pure(Right(tokenOpt))) + .serverLogic(tokenOpt => _ => logout(sessionService, tokenOpt)), + ) + + // Passkey registration start (authenticated) + val registerPasskeyStartRoute = interpreter.toRoutes( + AuthEndpoints.registerPasskeyStart + .serverSecurityLogic(token => validateSession(sessionService, token, testMode)) + .serverLogic(_ => req => startPasskeyRegistration(webAuthnService, req)), + ) + + // Passkey registration finish (authenticated) + val registerPasskeyFinishRoute = interpreter.toRoutes( + AuthEndpoints.registerPasskeyFinish + .serverSecurityLogic(token => validateSession(sessionService, token, testMode)) + .serverLogic(_ => req => finishPasskeyRegistration(webAuthnService, req)), + ) + + // Passkey login start (public) + val loginPasskeyStartRoute = interpreter.toRoutes( + AuthEndpoints.loginPasskeyStart.serverLogic(_ => startPasskeyLogin(webAuthnService)), + ) + + // Passkey login finish - validates and returns session cookie + val loginPasskeyFinishRoute = interpreter.toRoutes( + AuthEndpoints.loginPasskeyFinish.serverLogic(req => finishPasskeyLogin(webAuthnService, sessionService, req)), + ) + + // List passkeys (authenticated) + val listPasskeysRoute = interpreter.toRoutes( + AuthEndpoints.listPasskeys + .serverSecurityLogic(token => validateSession(sessionService, token, testMode)) + .serverLogic(_ => _ => listPasskeys(passkeyRepo)), + ) + + // Delete passkey (authenticated) + val deletePasskeyRoute = interpreter.toRoutes( + AuthEndpoints.deletePasskey + .serverSecurityLogic(token => validateSession(sessionService, token, testMode)) + .serverLogic(_ => credId => deletePasskey(passkeyRepo, credId)), + ) + + statusRoute <+> + setupRoute <+> + loginRoute <+> + logoutRoute <+> + registerPasskeyStartRoute <+> + registerPasskeyFinishRoute <+> + loginPasskeyStartRoute <+> + loginPasskeyFinishRoute <+> + listPasskeysRoute <+> + deletePasskeyRoute + } + + /** Validates a session token and returns Unit if valid. Used by protected endpoints. + * + * In testMode, bypasses authentication entirely. Otherwise, requires a valid session token. + */ + def validateSession(sessionService: SessionService, tokenOpt: Option[String], testMode: Boolean): IO[Either[String, Unit]] = { + if testMode then { + IO.pure(Right(())) + } else { + tokenOpt match { + case Some(token) => + sessionService.validateSession(token).map { + case Some(_) => Right(()) + case None => Left("Unauthorized") + } + case None => IO.pure(Left("Unauthorized")) + } + } + } + + /** Checks if session token is valid, returns boolean for status endpoint. + * + * In testMode, returns true to bypass authentication entirely. + */ + private def checkLoginStatus( + sessionService: SessionService, + tokenOpt: Option[String], + testMode: Boolean, + ): IO[Either[String, Boolean]] = { + if testMode then { + // In test mode, always return logged in + IO.pure(Right(true)) + } else { + tokenOpt match { + case Some(token) => + sessionService.validateSession(token).map { + case Some(_) => Right(true) + case None => Right(false) + } + case None => IO.pure(Right(false)) + } + } + } + + private def getAuthStatus( + authConfigRepo: AuthConfigRepository, + passkeyRepo: PasskeyCredentialRepository, + loggedIn: Boolean, + testMode: Boolean, + ): IO[Either[String, AuthStatus]] = { + // In test mode, return configured=true and loggedIn=true to bypass auth UI + if testMode then { + IO.pure(Right(AuthStatus(configured = true, passkeyCount = 0, loggedIn = true))) + } else { + for { + configOpt <- authConfigRepo.get + passkeyCount <- passkeyRepo.count + configured = configOpt.exists(_.passwordHash.isDefined) || passkeyCount > 0 + } yield Right(AuthStatus(configured, passkeyCount, loggedIn)) + } + } + + private def setupPassword( + authConfigRepo: AuthConfigRepository, + passwordService: PasswordService, + sessionService: SessionService, + req: SetupRequest, + ): IO[Either[String, CookieValueWithMeta]] = { + for { + configOpt <- authConfigRepo.get + result <- configOpt match { + case Some(config) if config.passwordHash.isDefined => + IO.pure(Left("Password already configured")) + case _ => + for { + hash <- passwordService.hash(req.password) + _ <- authConfigRepo.upsert(hash) + session <- sessionService.createSession() + } yield Right(sessionCookie(session.token)) + } + } yield result + } + + private def login( + authConfigRepo: AuthConfigRepository, + passwordService: PasswordService, + sessionService: SessionService, + req: LoginRequest, + ): IO[Either[String, CookieValueWithMeta]] = { + for { + configOpt <- authConfigRepo.get + result <- configOpt match { + case Some(config) if config.passwordHash.isDefined => + for { + valid <- passwordService.verify(req.password, config.passwordHash.get) + result <- if valid then { + sessionService.createSession().map(s => Right(sessionCookie(s.token))) + } else { + IO.pure(Left("Invalid password")) + } + } yield result + case _ => + IO.pure(Left("Authentication not configured")) + } + } yield result + } + + private def logout(sessionService: SessionService, tokenOpt: Option[String]): IO[Either[String, CookieValueWithMeta]] = { + for { + _ <- tokenOpt.fold(IO.unit)(token => sessionService.invalidateSession(token)) + } yield Right(clearCookie) + } + + private def listPasskeys(passkeyRepo: PasskeyCredentialRepository): IO[Either[String, List[PasskeyInfo]]] = { + passkeyRepo.findAll.map { credentials => + Right( + credentials.map { cred => + PasskeyInfo(cred.credentialId, cred.displayName, cred.createdAt, cred.lastUsedAt) + }, + ) + } + } + + private def deletePasskey(passkeyRepo: PasskeyCredentialRepository, credentialId: String): IO[Either[String, Unit]] = { + passkeyRepo.delete(credentialId).map(_ => Right(())) + } + + private def startPasskeyRegistration( + webAuthnService: WebAuthnService, + req: PasskeyRegisterStartRequest, + ): IO[Either[String, PasskeyRegistrationOptions]] = { + webAuthnService.startRegistration(req.displayName).map(Right(_)).handleError(e => Left(e.getMessage)) + } + + private def finishPasskeyRegistration( + webAuthnService: WebAuthnService, + req: PasskeyRegistrationResponse, + ): IO[Either[String, Unit]] = { + webAuthnService.finishRegistration(req).map(_ => Right(())).handleError(e => Left(e.getMessage)) + } + + private def startPasskeyLogin(webAuthnService: WebAuthnService): IO[Either[String, PasskeyAuthenticationOptions]] = { + webAuthnService.startAuthentication().map(Right(_)).handleError(e => Left(e.getMessage)) + } + + private def finishPasskeyLogin( + webAuthnService: WebAuthnService, + sessionService: SessionService, + req: PasskeyAuthenticationResponse, + ): IO[Either[String, CookieValueWithMeta]] = { + webAuthnService.finishAuthentication(req).attempt.flatMap { + case Right(_) => sessionService.createSession().map(s => Right(sessionCookie(s.token))) + case Left(error) => IO.pure(Left(error.getMessage)) + } + } +} diff --git a/backend/src/main/scala/ssbudget/backend/Main.scala b/backend/src/main/scala/ssbudget/backend/Main.scala new file mode 100644 index 0000000..bd0a693 --- /dev/null +++ b/backend/src/main/scala/ssbudget/backend/Main.scala @@ -0,0 +1,40 @@ +package ssbudget.backend + +import cats.effect.{IO, IOApp, Resource} +import cats.implicits.* +import com.comcast.ip4s.Port + +import ssbudget.backend.db.{Database, Repositories} + +import java.nio.file.{Files, Paths} + +object Main extends IOApp.Simple { + + private val dbPath = sys.env.getOrElse("SSBUDGET_DB_PATH", "data/ssbudget.db") + private val jdbcUrl = s"jdbc:sqlite:$dbPath" + private val testMode = sys.env.contains("SSBUDGET_TEST_MODE") + private val serverPort = Port.fromString(sys.env.getOrElse("SSBUDGET_PORT", "8080")).getOrElse(Port.fromInt(8080).get) + + private def ensureDbDirectoryExists: IO[Unit] = IO.blocking { + val path = Paths.get(dbPath).getParent + if path != null && !Files.exists(path) then { + Files.createDirectories(path) + } + } + + override def run: IO[Unit] = { + val resources = for { + _ <- Resource.eval(IO.println(s"Using database: $jdbcUrl")) + _ <- Resource.eval(ensureDbDirectoryExists) + xa <- Database.migrateAndTransactor(jdbcUrl) + repos = Repositories.fromTransactor(xa) + _ <- Resource.eval(IO.println("Database migrated successfully")) + s <- ServerBuilder.build(repos, xa, serverPort, testMode, dbPath) + } yield s + + resources.use { s => + IO.println(s"Server started at http://localhost:${s.address.getPort}") *> + IO.never + } + } +} diff --git a/backend/src/main/scala/ssbudget/backend/Routes.scala b/backend/src/main/scala/ssbudget/backend/Routes.scala new file mode 100644 index 0000000..1e814ed --- /dev/null +++ b/backend/src/main/scala/ssbudget/backend/Routes.scala @@ -0,0 +1,433 @@ +package ssbudget.backend + +import cats.effect.IO +import cats.implicits.* +import doobie.hikari.HikariTransactor +import org.http4s.HttpRoutes +import org.sqlite.SQLiteConnection +import sttp.capabilities.fs2.Fs2Streams +import sttp.tapir.* +import sttp.tapir.server.ServerEndpoint +import sttp.tapir.server.http4s.Http4sServerInterpreter +import ssbudget.backend.auth.SessionService +import ssbudget.backend.db.Repositories +import ssbudget.backend.service.CurrencyService +import ssbudget.shared.api.* +import ssbudget.shared.model.* + +import java.nio.file.{Files as JFiles, Paths} +import java.time.Instant +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.UUID + +object Routes { + + /** Result type for route handlers - IO with Either for error handling. */ + type Result[T] = IO[Either[String, T]] + + def make( + repos: Repositories, + xa: HikariTransactor[IO], + dbPath: String, + sessionService: SessionService, + currencyService: CurrencyService, + testMode: Boolean = false, + ): HttpRoutes[IO] = { + val interpreter = Http4sServerInterpreter[IO]() + + def validateSession(tokenOpt: Option[String]): IO[Either[String, Unit]] = + AuthRoutes.validateSession(sessionService, tokenOpt, testMode) + + def route[I, O](ep: Endpoint[Option[String], I, String, O, Any])(h: I => Result[O]): ServerEndpoint[Any, IO] = + ep.serverSecurityLogic(validateSession).serverLogic(_ => h) + + val routes = List( + // Accounts + route(Endpoints.accounts.list)(_ => repos.accounts.findAll.map(Right(_))), + route(Endpoints.accounts.create)(createAccount(repos)), + route(Endpoints.accounts.delete)(deleteAccount(repos)), + // Balances + route(Endpoints.balances.listLatest)(_ => repos.balanceSnapshots.findAllLatest.map(Right(_))), + route(Endpoints.balances.create)(createBalanceSnapshot(repos)), + // Budget items + route(Endpoints.budgetItems.list)(_ => repos.expenseDefinitions.findAll.map(Right(_))), + route(Endpoints.budgetItems.create)(createBudgetItem(repos)), + route(Endpoints.budgetItems.update) { case (id, dto) => updateBudgetItem(repos)(id, dto) }, + route(Endpoints.budgetItems.delete)(deleteBudgetItem(repos)), + // Expense records + route(Endpoints.expenseRecords.listCurrent)(_ => listCurrentPeriodRecords(repos)), + route(Endpoints.expenseRecords.pay) { case (id, dto) => payExpenseRecord(repos)(id, dto) }, + route(Endpoints.expenseRecords.unpay)(unpayExpenseRecord(repos)), + // Periods + route(Endpoints.periods.list)(_ => repos.periods.findAll.map(Right(_))), + route(Endpoints.periods.startNew)(_ => startNewPeriod(repos)), + // Savings accounts + route(Endpoints.savingsAccounts.list)(_ => repos.savingsAccounts.findAll.map(Right(_))), + route(Endpoints.savingsAccounts.create)(createSavingsAccount(repos)), + route(Endpoints.savingsAccounts.update) { case (id, dto) => updateSavingsAccount(repos)(id, dto) }, + route(Endpoints.savingsAccounts.updateBalance) { case (id, dto) => updateSavingsAccountBalance(repos)(id, dto) }, + route(Endpoints.savingsAccounts.delete)(deleteSavingsAccount(repos)), + // Savings transactions + route(Endpoints.savingsTransactions.listCurrent)(_ => listCurrentPeriodSavingsTransactions(repos)), + route(Endpoints.savingsTransactions.create)(createSavingsTransaction(repos)), + route(Endpoints.savingsTransactions.delete)(deleteSavingsTransaction(repos)), + // Exchange rates (all rates to primary currency) + route(Endpoints.exchangeRates.getAll)(_ => getAllExchangeRates(repos)), + // Currency settings + route(Endpoints.currencies.getSettings)(_ => currencyService.getSettings().map(Right(_))), + route(Endpoints.currencies.enable)(dto => currencyService.enableCurrency(dto.code)), + route(Endpoints.currencies.disable)(code => currencyService.disableCurrency(code)), + route(Endpoints.currencies.setPrimary)(dto => currencyService.setPrimaryCurrency(dto.code)), + route(Endpoints.currencies.refreshRates)(_ => currencyService.refreshRates()), + // Database import/export + route(Endpoints.database.download)(_ => exportDatabase(dbPath)), + route(Endpoints.database.`import`)(bytes => importDatabase(xa, dbPath, bytes)), + ) ++ (if testMode then List(route(Endpoints.test.reset)(_ => resetDatabase(repos))) else Nil) + + interpreter.toRoutes(routes) + } + + private def listCurrentPeriodRecords(repos: Repositories): Result[List[ExpenseRecord]] = { + for { + currentPeriod <- repos.periods.findCurrent + records <- currentPeriod.fold(IO.pure(List.empty[ExpenseRecord]))(p => repos.expenseRecords.findByPeriod(p.id)) + } yield Right(records) + } + + private def listCurrentPeriodSavingsTransactions(repos: Repositories): Result[List[SavingsTransaction]] = { + for { + currentPeriod <- repos.periods.findCurrent + txns <- currentPeriod.fold(IO.pure(List.empty[SavingsTransaction]))(p => repos.savingsTransactions.findByPeriodId(p.id)) + } yield Right(txns) + } + + private def createAccount(repos: Repositories)(dto: CreateAccount): Result[AccountResponse] = { + val accountId = AccountId(UUID.randomUUID().toString) + val snapshotId = BalanceSnapshotId(UUID.randomUUID().toString) + val now = Instant.now() + + val account = Account(accountId, dto.name, dto.currency) + val snapshot = BalanceSnapshot(snapshotId, accountId, 0L, dto.currency, now) + + for { + _ <- repos.accounts.create(account) + _ <- repos.balanceSnapshots.create(snapshot) + } yield Right(AccountResponse(account, snapshot)) + } + + private def deleteAccount(repos: Repositories)(id: AccountId): Result[Unit] = { + for { + // Delete related balance snapshots first + _ <- repos.balanceSnapshots.deleteByAccountId(id) + _ <- repos.accounts.delete(id) + } yield Right(()) + } + + private def createBalanceSnapshot(repos: Repositories)(dto: CreateBalanceSnapshot): Result[BalanceSnapshot] = { + for { + accountOpt <- repos.accounts.findById(dto.accountId) + result <- accountOpt match { + case Some(account) => + val snapshotId = BalanceSnapshotId(UUID.randomUUID().toString) + val now = Instant.now() + val snapshot = BalanceSnapshot(snapshotId, dto.accountId, dto.amountCents, account.currency, now) + repos.balanceSnapshots.create(snapshot).as(Right(snapshot)) + case None => + IO.pure(Left(s"Account not found: ${dto.accountId.value}")) + } + } yield result + } + + private def createBudgetItem(repos: Repositories)(dto: CreateBudgetItem): Result[BudgetItemDefinition] = { + val itemId = ExpenseDefId(UUID.randomUUID().toString) + val item = BudgetItemDefinition(itemId, dto.name, dto.itemType, EstimateMode.Fixed, Some(dto.estimateCents), dto.currency) + + for { + _ <- repos.expenseDefinitions.create(item) + // If it's a planned expense or income, create an expense record for the current period + currentPeriod <- repos.periods.findCurrent + _ <- currentPeriod match { + case Some(period) if dto.itemType == BudgetItemType.PlannedExpense || dto.itemType == BudgetItemType.PlannedIncome => + val recordId = ExpenseRecordId(UUID.randomUUID().toString) + val record = ExpenseRecord(recordId, period.id, itemId, None, None) + repos.expenseRecords.create(record) + case _ => IO.unit + } + } yield Right(item) + } + + private def updateBudgetItem(repos: Repositories)(id: ExpenseDefId, dto: UpdateBudgetItem): Result[BudgetItemDefinition] = { + for { + existingOpt <- repos.expenseDefinitions.findById(id) + result <- existingOpt match { + case Some(existing) => + val updated = + existing.copy(name = dto.name, itemType = dto.itemType, fixedEstimate = Some(dto.estimateCents), currency = dto.currency) + repos.expenseDefinitions.update(updated).as(Right(updated)) + case None => + IO.pure(Left(s"Budget item not found: ${id.value}")) + } + } yield result + } + + private def deleteBudgetItem(repos: Repositories)(id: ExpenseDefId): Result[Unit] = { + for { + // Note: expense records referencing this item should be deleted or we could have FK issues + // For now, just delete the definition (assuming cascade or manual cleanup) + _ <- repos.expenseDefinitions.delete(id) + } yield Right(()) + } + + private def payExpenseRecord(repos: Repositories)(expenseDefId: ExpenseDefId, dto: PayBudgetItem): Result[ExpenseRecord] = { + for { + currentPeriod <- repos.periods.findCurrent + result <- currentPeriod match { + case Some(period) => + for { + recordOpt <- repos.expenseRecords.findByPeriodAndExpense(period.id, expenseDefId) + record <- recordOpt match { + case Some(record) => + val now = Instant.now() + repos.expenseRecords + .markAsPaid(record.id, dto.amountCents, now) + .as( + record.copy(paidAmount = Some(dto.amountCents), paidAt = Some(now)), + ) + case None => + IO.raiseError( + new Exception(s"Expense record not found for period ${period.id.value} and expense ${expenseDefId.value}"), + ) + } + } yield Right(record) + case None => + IO.pure(Left("No current period found")) + } + } yield result + } + + private def unpayExpenseRecord(repos: Repositories)(expenseDefId: ExpenseDefId): Result[ExpenseRecord] = { + for { + currentPeriod <- repos.periods.findCurrent + result <- currentPeriod match { + case Some(period) => + for { + recordOpt <- repos.expenseRecords.findByPeriodAndExpense(period.id, expenseDefId) + record <- recordOpt match { + case Some(record) => + // Set paid_amount and paid_at to NULL + val updated = record.copy(paidAmount = None, paidAt = None) + // We need to update the record - let's use markAsPaid with special handling + // Actually, we need to add an unpay method to the repository + // For now, we'll delete and recreate + repos.expenseRecords.delete(record.id) *> + repos.expenseRecords.create(updated).as(updated) + case None => + IO.raiseError(new Exception(s"Expense record not found")) + } + } yield Right(record) + case None => + IO.pure(Left("No current period found")) + } + } yield result + } + + private def startNewPeriod(repos: Repositories): Result[Period] = { + val now = Instant.now() + val newPeriodId = PeriodId(UUID.randomUUID().toString) + val newPeriod = Period(newPeriodId, now, None) + + for { + // Close current period + currentPeriod <- repos.periods.findCurrent + _ <- currentPeriod.fold(IO.unit)(p => repos.periods.close(p.id, now)) + // Create new period + _ <- repos.periods.create(newPeriod) + // Create expense records for all planned expenses and incomes + budgetItems <- repos.expenseDefinitions.findAll + plannedItems = budgetItems.filter(i => i.itemType == BudgetItemType.PlannedExpense || i.itemType == BudgetItemType.PlannedIncome) + _ <- plannedItems.traverse { item => + val recordId = ExpenseRecordId(UUID.randomUUID().toString) + val record = ExpenseRecord(recordId, newPeriodId, item.id, None, None) + repos.expenseRecords.create(record) + } + } yield Right(newPeriod) + } + + private def createSavingsAccount(repos: Repositories)(dto: CreateSavingsAccount): Result[SavingsAccount] = { + val accountId = SavingsAccountId(UUID.randomUUID().toString) + val account = SavingsAccount(accountId, dto.name, dto.currency, 0L, dto.plannedMonthly) + + repos.savingsAccounts.create(account).as(Right(account)) + } + + private def updateSavingsAccount(repos: Repositories)(id: SavingsAccountId, dto: UpdateSavingsAccount): Result[SavingsAccount] = { + for { + existingOpt <- repos.savingsAccounts.findById(id) + result <- existingOpt match { + case Some(existing) => + val updated = existing.copy(name = dto.name, currency = dto.currency, plannedMonthly = dto.plannedMonthly) + repos.savingsAccounts.update(updated).as(Right(updated)) + case None => + IO.pure(Left(s"Savings account not found: ${id.value}")) + } + } yield result + } + + private def updateSavingsAccountBalance(repos: Repositories)(id: SavingsAccountId, dto: UpdateSavingsAccountBalance): Result[SavingsAccount] = { + for { + existingOpt <- repos.savingsAccounts.findById(id) + result <- existingOpt match { + case Some(existing) => + val updated = existing.copy(currentBalance = dto.newBalance) + repos.savingsAccounts.updateBalance(id, dto.newBalance).as(Right(updated)) + case None => + IO.pure(Left(s"Savings account not found: ${id.value}")) + } + } yield result + } + + private def deleteSavingsAccount(repos: Repositories)(id: SavingsAccountId): Result[Unit] = { + for { + // Delete related transactions first + _ <- repos.savingsTransactions.deleteByAccountId(id) + _ <- repos.savingsAccounts.delete(id) + } yield Right(()) + } + + private def createSavingsTransaction(repos: Repositories)(dto: CreateSavingsTransaction): Result[SavingsTransactionResponse] = { + for { + currentPeriod <- repos.periods.findCurrent + accountOpt <- repos.savingsAccounts.findById(dto.accountId) + result <- (currentPeriod, accountOpt) match { + case (Some(period), Some(account)) => + val txnId = SavingsTransactionId(UUID.randomUUID().toString) + val now = Instant.now() + val txn = SavingsTransaction(txnId, dto.accountId, period.id, dto.amount, dto.note, now) + + // Update account balance + val newBalance = account.currentBalance + dto.amount + val updatedAccount = account.copy(currentBalance = newBalance) + + for { + _ <- repos.savingsTransactions.create(txn) + _ <- repos.savingsAccounts.updateBalance(dto.accountId, newBalance) + } yield Right(SavingsTransactionResponse(txn, updatedAccount)) + case (None, _) => + IO.pure(Left("No current period found")) + case (_, None) => + IO.pure(Left(s"Savings account not found: ${dto.accountId.value}")) + } + } yield result + } + + private def deleteSavingsTransaction(repos: Repositories)(id: SavingsTransactionId): Result[SavingsAccount] = { + for { + txnOpt <- repos.savingsTransactions.findById(id) + result <- txnOpt match { + case Some(txn) => + for { + accountOpt <- repos.savingsAccounts.findById(txn.accountId) + account <- accountOpt match { + case Some(acc) => + // Reverse the balance change + val newBalance = acc.currentBalance - txn.amount + val updatedAccount = acc.copy(currentBalance = newBalance) + for { + _ <- repos.savingsTransactions.delete(id) + _ <- repos.savingsAccounts.updateBalance(txn.accountId, newBalance) + } yield updatedAccount + case None => + IO.raiseError(new Exception(s"Account not found: ${txn.accountId.value}")) + } + } yield Right(account) + case None => + IO.pure(Left(s"Savings transaction not found: ${id.value}")) + } + } yield result + } + + private def resetDatabase(repos: Repositories): Result[Unit] = { + // This is a test-only endpoint to reset the database + // In a real implementation, you'd want to be more careful here + IO.pure(Right(())) + } + + private def getAllExchangeRates(repos: Repositories): Result[List[ExchangeRate]] = { + // Get latest exchange rates for all enabled currencies to the primary currency + for { + primaryOpt <- repos.currencySettings.findPrimary + enabled <- repos.currencySettings.findAll + primary = primaryOpt.map(_.code).getOrElse(Currency.PLN) + // For each non-primary enabled currency, get latest rate to primary + rates <- enabled + .filterNot(_.code == primary) + .traverse { setting => + repos.exchangeRates.findLatest(setting.code, primary) + } + } yield Right(rates.flatten) + } + + private def exportDatabase(dbPath: String): Result[(String, Array[Byte])] = { + val path = Paths.get(dbPath) + val timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HHmmss")) + val filename = s"ssbudget_backup_$timestamp.db" + + IO.blocking { + if JFiles.exists(path) then { + val bytes = JFiles.readAllBytes(path) + val contentDisposition = s"""attachment; filename="$filename"""" + Right((contentDisposition, bytes)) + } else { + Left("Database file not found") + } + } + } + + private def importDatabase(xa: HikariTransactor[IO], dbPath: String, bytes: Array[Byte]): Result[String] = { + val tempPath = Paths.get(dbPath + ".import.tmp") + + // Validate SQLite header + def isValidSqlite: Boolean = { + if bytes.length >= 16 then { + val header = bytes.take(16) + val expected = "SQLite format 3\u0000".getBytes("UTF-8") + header.sameElements(expected) + } else { + false + } + } + + if !isValidSqlite then { + IO.pure(Left("Invalid SQLite file. Upload must be a valid SQLite database.")) + } else { + val writeTemp = IO.blocking { + val parentDir = tempPath.getParent + if parentDir != null && !JFiles.exists(parentDir) then { + JFiles.createDirectories(parentDir) + } + JFiles.write(tempPath, bytes) + } + + val restoreDb = IO.blocking { + val hikariDs = xa.kernel + val destConn = hikariDs.getConnection.unwrap(classOf[SQLiteConnection]) + try { + destConn.getDatabase.restore("main", tempPath.toAbsolutePath.toString, null) + } finally { + destConn.close() + } + } + + val cleanupTemp = IO.blocking { + if JFiles.exists(tempPath) then { + JFiles.delete(tempPath) + } + } + + (writeTemp *> restoreDb *> cleanupTemp) + .as(Right("Database imported successfully. Please refresh the page to see the updated data.")) + .handleError(e => Left(s"Import failed: ${e.getMessage}")) + } + } +} diff --git a/backend/src/main/scala/ssbudget/backend/ServerBuilder.scala b/backend/src/main/scala/ssbudget/backend/ServerBuilder.scala new file mode 100644 index 0000000..67fb6bf --- /dev/null +++ b/backend/src/main/scala/ssbudget/backend/ServerBuilder.scala @@ -0,0 +1,81 @@ +package ssbudget.backend + +import cats.effect.{IO, Resource} +import cats.implicits.* +import com.comcast.ip4s.{Host, Port, host} +import doobie.hikari.HikariTransactor +import org.http4s.ember.server.EmberServerBuilder +import org.http4s.server.Server +import sttp.client3.httpclient.cats.HttpClientCatsBackend +import sttp.tapir.server.http4s.Http4sServerInterpreter + +import ssbudget.backend.auth.{PasswordService, SessionService, WebAuthnService} +import ssbudget.backend.db.Repositories +import ssbudget.backend.service.CurrencyService +import ssbudget.shared.api.HealthEndpoint + +/** Reusable server builder for production and testing */ +object ServerBuilder { + + private val healthRoute = Http4sServerInterpreter[IO]().toRoutes( + HealthEndpoint.health.serverLogicSuccess(_ => IO.pure("ok")), + ) + + // WebAuthn configuration from environment + private def defaultRpId: String = sys.env.getOrElse("SSBUDGET_RP_ID", "localhost") + private def defaultRpName: String = sys.env.getOrElse("SSBUDGET_RP_NAME", "SSBudget") + private def defaultRpOrigins: Set[String] = sys.env + .get("SSBUDGET_RP_ORIGINS") + .map(_.split(",").toSet) + .getOrElse(Set("http://localhost:3000", "http://localhost:8080")) + + // Static files directory (for production deployment) + private val staticDir = sys.env.get("SSBUDGET_STATIC_DIR") + + /** Build a server resource with the given configuration */ + def build( + repos: Repositories, + xa: HikariTransactor[IO], + port: Port, + testMode: Boolean = false, + dbPath: String = "data/ssbudget.db", + webAuthnOrigins: Option[Set[String]] = None, + ): Resource[IO, Server] = { + val rpOrigins = webAuthnOrigins.getOrElse(defaultRpOrigins) + for { + sttpBackend <- HttpClientCatsBackend.resource[IO]() + webAuthnService <- Resource.eval(WebAuthnService(repos.passkeyCredentials, defaultRpId, defaultRpName, rpOrigins)) + server <- { + val passwordService = PasswordService() + val sessionService = SessionService(repos.sessions) + val currencyService = new CurrencyService(repos, sttpBackend) + + val authRoutes = AuthRoutes.make( + repos.authConfig, + repos.passkeyCredentials, + passwordService, + sessionService, + webAuthnService, + testMode, + ) + + // Routes now handle their own auth via Tapir's serverSecurityLogic + val dataRoutes = Routes.make(repos, xa, dbPath, sessionService, currencyService, testMode) + + // Static file routes for production (serves frontend build) + val staticRoutes = StaticRoutes.make(staticDir) + + // Static routes first for non-API paths, then API routes + // (staticRoutes only handles non-API paths via the make method) + val allRoutes = staticRoutes <+> healthRoute <+> authRoutes <+> dataRoutes + + EmberServerBuilder + .default[IO] + .withHost(host"0.0.0.0") + .withPort(port) + .withHttpApp(allRoutes.orNotFound) + .build + } + } yield server + } +} diff --git a/backend/src/main/scala/ssbudget/backend/StaticRoutes.scala b/backend/src/main/scala/ssbudget/backend/StaticRoutes.scala new file mode 100644 index 0000000..362c7e3 --- /dev/null +++ b/backend/src/main/scala/ssbudget/backend/StaticRoutes.scala @@ -0,0 +1,110 @@ +package ssbudget.backend + +import cats.data.OptionT +import cats.effect.IO +import fs2.io.file.{Files, Path} +import org.http4s.* +import org.http4s.dsl.io.* +import org.http4s.headers.`Content-Type` + +/** Serves static files from the frontend build directory */ +object StaticRoutes { + + private val defaultMimeTypes: Map[String, MediaType] = Map( + "html" -> MediaType.text.html, + "css" -> MediaType.text.css, + "js" -> MediaType.application.javascript, + "json" -> MediaType.application.json, + "png" -> MediaType.image.png, + "jpg" -> MediaType.image.jpeg, + "jpeg" -> MediaType.image.jpeg, + "gif" -> MediaType.image.gif, + "svg" -> MediaType.image.`svg+xml`, + "ico" -> MediaType.image.`x-icon`, + "woff" -> MediaType.font.woff, + "woff2" -> MediaType.font.woff2, + "ttf" -> MediaType.font.ttf, + "eot" -> MediaType.application.`vnd.ms-fontobject`, + "map" -> MediaType.application.json, + ) + + private def getMimeType(filename: String): MediaType = { + val ext = filename.lastIndexOf('.') match { + case -1 => "" + case i => filename.substring(i + 1).toLowerCase + } + defaultMimeTypes.getOrElse(ext, MediaType.application.`octet-stream`) + } + + /** Create routes that serve static files from the given directory. Falls back to index.html for SPA routing (any path not matching a file). + */ + def make(staticDir: Option[String]): HttpRoutes[IO] = { + staticDir match { + case None => + HttpRoutes.empty[IO] + case Some(dir) => + val basePath = Path(dir) + HttpRoutes[IO] { req => + val path = req.uri.path.renderString + // Skip API paths - let them fall through to API routes + if path.startsWith("/api") then { + OptionT.none[IO, Response[IO]] + } else if req.method != Method.GET && req.method != Method.HEAD then { + OptionT.none[IO, Response[IO]] + } else { + val requestedPath = req.uri.path.segments.mkString("/") + val filePath = if requestedPath.isEmpty then "index.html" else requestedPath + + OptionT.liftF( + serveFile(basePath, filePath, req.method == Method.HEAD).getOrElseF { + // SPA fallback: serve index.html for paths that don't match files + // But only for paths that look like routes (no file extension) + if !filePath.contains(".") then { + serveFile(basePath, "index.html", req.method == Method.HEAD).getOrElseF(NotFound()) + } else { + NotFound() + } + }, + ) + } + } + } + } + + private def serveFile(basePath: Path, relativePath: String, headOnly: Boolean): OptionT[IO, Response[IO]] = { + // Build path by joining segments properly (handles "assets/file.css" style paths) + val filePath = relativePath.split("/").filter(_.nonEmpty).foldLeft(basePath)(_ / _) + + // Security: ensure the resolved path is still under basePath + val normalizedBase = basePath.absolute.normalize + val normalizedFile = filePath.absolute.normalize + + OptionT( + if !normalizedFile.toString.startsWith(normalizedBase.toString) then { + IO.pure(None) + } else { + Files[IO].exists(filePath).flatMap { exists => + if exists then { + Files[IO].isRegularFile(filePath).flatMap { isFile => + if isFile then { + val mediaType = getMimeType(relativePath) + val contentType = `Content-Type`(mediaType) + if headOnly then { + // For HEAD requests, return headers without body + IO.pure(Some(Response[IO](Status.Ok).withContentType(contentType))) + } else { + val body = Files[IO].readAll(filePath) + IO.pure(Some(Response[IO](Status.Ok).withEntity(body).withContentType(contentType))) + } + } else { + IO.pure(None) + } + } + } else { + IO.pure(None) + } + } + }, + ) + } +} diff --git a/backend/src/main/scala/ssbudget/backend/auth/PasswordService.scala b/backend/src/main/scala/ssbudget/backend/auth/PasswordService.scala new file mode 100644 index 0000000..b4ac4da --- /dev/null +++ b/backend/src/main/scala/ssbudget/backend/auth/PasswordService.scala @@ -0,0 +1,26 @@ +package ssbudget.backend.auth + +import cats.effect.IO +import de.mkammerer.argon2.{Argon2, Argon2Factory} + +trait PasswordService { + def hash(password: String): IO[String] + def verify(password: String, hash: String): IO[Boolean] +} + +object PasswordService { + + def apply(): PasswordService = new PasswordServiceImpl() + + private class PasswordServiceImpl extends PasswordService { + private val argon2: Argon2 = Argon2Factory.create(Argon2Factory.Argon2Types.ARGON2id) + + override def hash(password: String): IO[String] = IO.blocking { + argon2.hash(10, 65536, 1, password.toCharArray) + } + + override def verify(password: String, hash: String): IO[Boolean] = IO.blocking { + argon2.verify(hash, password.toCharArray) + } + } +} diff --git a/backend/src/main/scala/ssbudget/backend/auth/SessionService.scala b/backend/src/main/scala/ssbudget/backend/auth/SessionService.scala new file mode 100644 index 0000000..cf5819d --- /dev/null +++ b/backend/src/main/scala/ssbudget/backend/auth/SessionService.scala @@ -0,0 +1,60 @@ +package ssbudget.backend.auth + +import cats.effect.IO +import ssbudget.backend.db.repository.{Session, SessionRepository} + +import java.security.SecureRandom +import java.time.{Duration, Instant} +import java.util.Base64 + +trait SessionService { + def createSession(): IO[Session] + def validateSession(token: String): IO[Option[Session]] + def invalidateSession(token: String): IO[Unit] + def cleanupExpiredSessions(): IO[Int] +} + +object SessionService { + private val SessionDuration: Duration = Duration.ofDays(30) + private val TokenLength: Int = 32 + + def apply(sessionRepository: SessionRepository): SessionService = + new SessionServiceImpl(sessionRepository) + + private class SessionServiceImpl(sessionRepository: SessionRepository) extends SessionService { + private val random = new SecureRandom() + + override def createSession(): IO[Session] = { + for { + token <- generateToken() + now = Instant.now() + expires = now.plus(SessionDuration) + session = Session(token, now, expires, now) + _ <- sessionRepository.create(session) + } yield session + } + + override def validateSession(token: String): IO[Option[Session]] = { + for { + sessionOpt <- sessionRepository.findByToken(token) + now = Instant.now() + validSession = sessionOpt.filter(s => s.expiresAt.isAfter(now)) + _ <- validSession.fold(IO.unit)(s => sessionRepository.updateLastUsed(s.token, now)) + } yield validSession + } + + override def invalidateSession(token: String): IO[Unit] = { + sessionRepository.delete(token) + } + + override def cleanupExpiredSessions(): IO[Int] = { + sessionRepository.deleteExpired(Instant.now()) + } + + private def generateToken(): IO[String] = IO.blocking { + val bytes = new Array[Byte](TokenLength) + random.nextBytes(bytes) + Base64.getUrlEncoder.withoutPadding().encodeToString(bytes) + } + } +} diff --git a/backend/src/main/scala/ssbudget/backend/auth/WebAuthnService.scala b/backend/src/main/scala/ssbudget/backend/auth/WebAuthnService.scala new file mode 100644 index 0000000..eccd764 --- /dev/null +++ b/backend/src/main/scala/ssbudget/backend/auth/WebAuthnService.scala @@ -0,0 +1,280 @@ +package ssbudget.backend.auth + +import cats.effect.{IO, Ref} +import com.yubico.webauthn.* +import com.yubico.webauthn.data.* +import ssbudget.backend.db.repository.{PasskeyCredential, PasskeyCredentialRepository} +import ssbudget.shared.api.* + +import java.time.Instant +import java.util.{Base64, Optional} +import scala.jdk.CollectionConverters.* +import scala.jdk.OptionConverters.* + +trait WebAuthnService { + def startRegistration(displayName: Option[String]): IO[PasskeyRegistrationOptions] + def finishRegistration(response: PasskeyRegistrationResponse): IO[Unit] + def startAuthentication(): IO[PasskeyAuthenticationOptions] + def finishAuthentication(response: PasskeyAuthenticationResponse): IO[Unit] +} + +object WebAuthnService { + + private val UserId = "ssbudget-user" + private val UserName = "SSBudget User" + + def apply( + credentialRepo: PasskeyCredentialRepository, + rpId: String, + rpName: String, + rpOrigins: Set[String], + ): IO[WebAuthnService] = { + for { + pendingRegRef <- Ref.of[IO, Option[(PublicKeyCredentialCreationOptions, Option[String])]](None) + pendingAuthRef <- Ref.of[IO, Option[AssertionRequest]](None) + } yield new WebAuthnServiceImpl(credentialRepo, rpId, rpName, rpOrigins, pendingRegRef, pendingAuthRef) + } + + private class WebAuthnServiceImpl( + credentialRepo: PasskeyCredentialRepository, + rpId: String, + rpName: String, + rpOrigins: Set[String], + pendingRegistration: Ref[IO, Option[(PublicKeyCredentialCreationOptions, Option[String])]], + pendingAuthentication: Ref[IO, Option[AssertionRequest]], + ) extends WebAuthnService { + + private val rp = RelyingPartyIdentity + .builder() + .id(rpId) + .name(rpName) + .build() + + private def createRelyingParty(): IO[RelyingParty] = { + credentialRepo.findAll.map { credentials => + val credentialRepository = new CredentialRepository { + override def getCredentialIdsForUsername(username: String): java.util.Set[PublicKeyCredentialDescriptor] = { + credentials + .map { cred => + PublicKeyCredentialDescriptor + .builder() + .id(ByteArray.fromBase64Url(cred.credentialId)) + .build() + } + .toSet + .asJava + } + + override def getUserHandleForUsername(username: String): Optional[ByteArray] = { + Optional.of(ByteArray.fromBase64Url(Base64.getUrlEncoder.withoutPadding().encodeToString(UserId.getBytes))) + } + + override def getUsernameForUserHandle(userHandle: ByteArray): Optional[String] = { + Optional.of(UserName) + } + + override def lookup(credentialId: ByteArray, userHandle: ByteArray): Optional[RegisteredCredential] = { + credentials + .find(_.credentialId == credentialId.getBase64Url) + .map { cred => + RegisteredCredential + .builder() + .credentialId(ByteArray.fromBase64Url(cred.credentialId)) + .userHandle(userHandle) + .publicKeyCose(new ByteArray(cred.publicKeyCose)) + .signatureCount(cred.signCount) + .build() + } + .toJava + } + + override def lookupAll(credentialId: ByteArray): java.util.Set[RegisteredCredential] = { + lookup(credentialId, ByteArray.fromBase64Url(Base64.getUrlEncoder.withoutPadding().encodeToString(UserId.getBytes))).toScala.toSet.asJava + } + } + + RelyingParty + .builder() + .identity(rp) + .credentialRepository(credentialRepository) + .origins(rpOrigins.asJava) + .build() + } + } + + override def startRegistration(displayName: Option[String]): IO[PasskeyRegistrationOptions] = { + createRelyingParty().flatMap { relyingParty => + val userIdentity = UserIdentity + .builder() + .name(UserName) + .displayName(displayName.getOrElse(UserName)) + .id(ByteArray.fromBase64Url(Base64.getUrlEncoder.withoutPadding().encodeToString(UserId.getBytes))) + .build() + + val authenticatorSelection = AuthenticatorSelectionCriteria + .builder() + .residentKey(ResidentKeyRequirement.PREFERRED) + .userVerification(UserVerificationRequirement.PREFERRED) + .build() + + val options = relyingParty.startRegistration( + StartRegistrationOptions + .builder() + .user(userIdentity) + .authenticatorSelection(authenticatorSelection) + .build(), + ) + + pendingRegistration.set(Some((options, displayName))).as { + PasskeyRegistrationOptions( + challenge = options.getChallenge.getBase64Url, + rpId = rpId, + rpName = rpName, + userId = options.getUser.getId.getBase64Url, + userName = options.getUser.getName, + timeout = options.getTimeout.toScala.map(_.longValue()).getOrElse(60000L), + attestation = options.getAttestation.getValue, + authenticatorSelection = AuthenticatorSelection( + authenticatorAttachment = options.getAuthenticatorSelection.toScala + .flatMap(_.getAuthenticatorAttachment.toScala.map(_.getValue)), + residentKey = options.getAuthenticatorSelection.toScala + .flatMap(_.getResidentKey.toScala.map(_.getValue)) + .getOrElse("preferred"), + userVerification = options.getAuthenticatorSelection.toScala + .flatMap(_.getUserVerification.toScala.map(_.getValue)) + .getOrElse("preferred"), + ), + pubKeyCredParams = options.getPubKeyCredParams.asScala.toList.map { param => + PubKeyCredParam(param.getType.getId, param.getAlg.getId.toInt) + }, + ) + } + } + } + + override def finishRegistration(response: PasskeyRegistrationResponse): IO[Unit] = { + pendingRegistration.getAndSet(None).flatMap { + case Some((options, displayName)) => + createRelyingParty().flatMap { relyingParty => + val clientResponse = PublicKeyCredential.parseRegistrationResponseJson(toRegistrationJson(response)) + + val result = relyingParty.finishRegistration( + FinishRegistrationOptions + .builder() + .request(options) + .response(clientResponse) + .build(), + ) + + val credential = PasskeyCredential( + credentialId = result.getKeyId.getId.getBase64Url, + publicKeyCose = result.getPublicKeyCose.getBytes, + signCount = result.getSignatureCount, + displayName = displayName, + createdAt = Instant.now(), + lastUsedAt = None, + ) + + credentialRepo.create(credential) + } + case None => + IO.raiseError(new Exception("No pending registration")) + } + } + + override def startAuthentication(): IO[PasskeyAuthenticationOptions] = { + createRelyingParty().flatMap { relyingParty => + credentialRepo.findAll.flatMap { credentials => + val request = relyingParty.startAssertion( + StartAssertionOptions.builder().build(), + ) + + pendingAuthentication.set(Some(request)).as { + PasskeyAuthenticationOptions( + challenge = request.getPublicKeyCredentialRequestOptions.getChallenge.getBase64Url, + rpId = rpId, + timeout = request.getPublicKeyCredentialRequestOptions.getTimeout.toScala.map(_.longValue()).getOrElse(60000L), + userVerification = request.getPublicKeyCredentialRequestOptions.getUserVerification.toScala.map(_.getValue).getOrElse("preferred"), + allowCredentials = credentials.map { cred => + AllowCredential( + `type` = "public-key", + id = cred.credentialId, + transports = None, + ) + }, + ) + } + } + } + } + + override def finishAuthentication(response: PasskeyAuthenticationResponse): IO[Unit] = { + pendingAuthentication.getAndSet(None).flatMap { + case Some(request) => + createRelyingParty().flatMap { relyingParty => + val clientResponse = PublicKeyCredential.parseAssertionResponseJson(toAssertionJson(response)) + + val result = relyingParty.finishAssertion( + FinishAssertionOptions + .builder() + .request(request) + .response(clientResponse) + .build(), + ) + + if result.isSuccess then { + val credId = result.getCredential.getCredentialId.getBase64Url + credentialRepo.updateSignCount(credId, result.getSignatureCount, Instant.now()) + } else { + IO.raiseError(new Exception("Authentication failed")) + } + } + case None => + IO.raiseError(new Exception("No pending authentication")) + } + } + + private def toRegistrationJson(response: PasskeyRegistrationResponse): String = { + import io.circe.syntax.* + import io.circe.{Json, JsonObject} + + // The library expects a specific JSON format with clientExtensionResults + val responseObj = JsonObject( + "clientDataJSON" -> Json.fromString(response.response.clientDataJSON), + "attestationObject" -> Json.fromString(response.response.attestationObject), + ) + + val json = JsonObject( + "id" -> Json.fromString(response.id), + "rawId" -> Json.fromString(response.rawId), + "type" -> Json.fromString(response.`type`), + "response" -> Json.fromJsonObject(responseObj), + "clientExtensionResults" -> Json.fromJsonObject(JsonObject.empty), + ) + + Json.fromJsonObject(json).noSpaces + } + + private def toAssertionJson(response: PasskeyAuthenticationResponse): String = { + import io.circe.syntax.* + import io.circe.{Json, JsonObject} + + val responseObj = JsonObject( + "clientDataJSON" -> Json.fromString(response.response.clientDataJSON), + "authenticatorData" -> Json.fromString(response.response.authenticatorData), + "signature" -> Json.fromString(response.response.signature), + "userHandle" -> response.response.userHandle.map(Json.fromString).getOrElse(Json.Null), + ) + + val json = JsonObject( + "id" -> Json.fromString(response.id), + "rawId" -> Json.fromString(response.rawId), + "type" -> Json.fromString(response.`type`), + "response" -> Json.fromJsonObject(responseObj), + "clientExtensionResults" -> Json.fromJsonObject(JsonObject.empty), + ) + + Json.fromJsonObject(json).noSpaces + } + } +} diff --git a/backend/src/main/scala/ssbudget/backend/db/Database.scala b/backend/src/main/scala/ssbudget/backend/db/Database.scala new file mode 100644 index 0000000..db17f43 --- /dev/null +++ b/backend/src/main/scala/ssbudget/backend/db/Database.scala @@ -0,0 +1,35 @@ +package ssbudget.backend.db + +import cats.effect.{IO, Resource} +import doobie.hikari.HikariTransactor +import doobie.util.ExecutionContexts +import org.flywaydb.core.Flyway + +object Database { + + def migrateAndTransactor(jdbcUrl: String): Resource[IO, HikariTransactor[IO]] = { + for { + ce <- ExecutionContexts.fixedThreadPool[IO](32) + xa <- HikariTransactor.newHikariTransactor[IO]( + "org.sqlite.JDBC", + jdbcUrl, + "", // no username for SQLite + "", // no password for SQLite + ce, + ) + // Run migrations using the HikariCP data source to ensure + // in-memory databases (with shared cache) keep their state + _ <- Resource.eval(migrateWithDataSource(xa)) + } yield xa + } + + private def migrateWithDataSource(xa: HikariTransactor[IO]): IO[Unit] = IO.blocking { + val hikariDataSource = xa.kernel + Flyway + .configure() + .dataSource(hikariDataSource) + .load() + .migrate() + () + } +} diff --git a/backend/src/main/scala/ssbudget/backend/db/DoobieMeta.scala b/backend/src/main/scala/ssbudget/backend/db/DoobieMeta.scala new file mode 100644 index 0000000..ab9676b --- /dev/null +++ b/backend/src/main/scala/ssbudget/backend/db/DoobieMeta.scala @@ -0,0 +1,48 @@ +package ssbudget.backend.db + +import cats.implicits.catsSyntaxEitherId +import doobie.* +import doobie.implicits.* +import ssbudget.shared.model.* + +import java.time.Instant + +object DoobieMeta { + + // ID types + given Meta[AccountId] = Meta[String].timap(AccountId.apply)(_.value) + given Meta[ExpenseDefId] = Meta[String].timap(ExpenseDefId.apply)(_.value) + given Meta[PeriodId] = Meta[String].timap(PeriodId.apply)(_.value) + given Meta[ExpenseRecordId] = Meta[String].timap(ExpenseRecordId.apply)(_.value) + given Meta[BalanceSnapshotId] = Meta[String].timap(BalanceSnapshotId.apply)(_.value) + given Meta[SavingsAccountId] = Meta[String].timap(SavingsAccountId.apply)(_.value) + given Meta[SavingsTransactionId] = Meta[String].timap(SavingsTransactionId.apply)(_.value) + + // Value types + given Meta[Currency] = Meta[String].timap(Currency.apply)(_.code) + + given Meta[BudgetItemType] = Meta[String].tiemap { + case "planned_expense" => BudgetItemType.PlannedExpense.asRight + case "estimated_expense" => BudgetItemType.EstimatedExpense.asRight + case "planned_income" => BudgetItemType.PlannedIncome.asRight + case other => Left(s"Unknown budget item type: $other") + } { + case BudgetItemType.PlannedExpense => "planned_expense" + case BudgetItemType.EstimatedExpense => "estimated_expense" + case BudgetItemType.PlannedIncome => "planned_income" + } + + given Meta[EstimateMode] = Meta[String].tiemap { + case "fixed" => EstimateMode.Fixed.asRight + case "last_month" => EstimateMode.LastMonth.asRight + case "average" => EstimateMode.Average.asRight + case other => Left(s"Unknown estimate mode: $other") + } { + case EstimateMode.Fixed => "fixed" + case EstimateMode.LastMonth => "last_month" + case EstimateMode.Average => "average" + } + + // Date/Time types (stored as TEXT in SQLite) + given Meta[Instant] = Meta[String].timap(Instant.parse)(_.toString) +} diff --git a/backend/src/main/scala/ssbudget/backend/db/Repositories.scala b/backend/src/main/scala/ssbudget/backend/db/Repositories.scala new file mode 100644 index 0000000..9ab6bab --- /dev/null +++ b/backend/src/main/scala/ssbudget/backend/db/Repositories.scala @@ -0,0 +1,39 @@ +package ssbudget.backend.db + +import doobie.Transactor +import cats.effect.IO +import ssbudget.backend.db.repository.* + +final case class Repositories( + accounts: AccountRepository, + expenseDefinitions: ExpenseDefinitionRepository, + periods: PeriodRepository, + expenseRecords: ExpenseRecordRepository, + balanceSnapshots: BalanceSnapshotRepository, + exchangeRates: ExchangeRateRepository, + savingsAccounts: SavingsAccountRepository, + savingsTransactions: SavingsTransactionRepository, + authConfig: AuthConfigRepository, + sessions: SessionRepository, + passkeyCredentials: PasskeyCredentialRepository, + currencySettings: CurrencySettingsRepository, +) + +object Repositories { + def fromTransactor(xa: Transactor[IO]): Repositories = { + Repositories( + accounts = new AccountRepositoryImpl(xa), + expenseDefinitions = new ExpenseDefinitionRepositoryImpl(xa), + periods = new PeriodRepositoryImpl(xa), + expenseRecords = new ExpenseRecordRepositoryImpl(xa), + balanceSnapshots = new BalanceSnapshotRepositoryImpl(xa), + exchangeRates = new ExchangeRateRepositoryImpl(xa), + savingsAccounts = new SavingsAccountRepositoryImpl(xa), + savingsTransactions = new SavingsTransactionRepositoryImpl(xa), + authConfig = new AuthConfigRepositoryImpl(xa), + sessions = new SessionRepositoryImpl(xa), + passkeyCredentials = new PasskeyCredentialRepositoryImpl(xa), + currencySettings = new CurrencySettingsRepositoryImpl(xa), + ) + } +} diff --git a/backend/src/main/scala/ssbudget/backend/db/repository/AccountRepository.scala b/backend/src/main/scala/ssbudget/backend/db/repository/AccountRepository.scala new file mode 100644 index 0000000..4531f68 --- /dev/null +++ b/backend/src/main/scala/ssbudget/backend/db/repository/AccountRepository.scala @@ -0,0 +1,57 @@ +package ssbudget.backend.db.repository + +import cats.effect.IO +import doobie.* +import doobie.implicits.* +import ssbudget.backend.db.DoobieMeta.given +import ssbudget.shared.model.* + +trait AccountRepository { + def create(account: Account): IO[Unit] + def findById(id: AccountId): IO[Option[Account]] + def findAll: IO[List[Account]] + def update(account: Account): IO[Unit] + def delete(id: AccountId): IO[Unit] + def existsWithCurrency(currency: Currency): IO[Boolean] +} + +class AccountRepositoryImpl(xa: Transactor[IO]) extends AccountRepository { + + override def create(account: Account): IO[Unit] = { + sql""" + INSERT INTO accounts (id, name, currency) + VALUES (${account.id}, ${account.name}, ${account.currency}) + """.update.run.transact(xa).void + } + + override def findById(id: AccountId): IO[Option[Account]] = { + sql""" + SELECT id, name, currency FROM accounts WHERE id = $id + """.query[Account].option.transact(xa) + } + + override def findAll: IO[List[Account]] = { + sql""" + SELECT id, name, currency FROM accounts ORDER BY name + """.query[Account].to[List].transact(xa) + } + + override def update(account: Account): IO[Unit] = { + sql""" + UPDATE accounts SET name = ${account.name}, currency = ${account.currency} + WHERE id = ${account.id} + """.update.run.transact(xa).void + } + + override def delete(id: AccountId): IO[Unit] = { + sql""" + DELETE FROM accounts WHERE id = $id + """.update.run.transact(xa).void + } + + override def existsWithCurrency(currency: Currency): IO[Boolean] = { + sql""" + SELECT EXISTS(SELECT 1 FROM accounts WHERE currency = $currency) + """.query[Boolean].unique.transact(xa) + } +} diff --git a/backend/src/main/scala/ssbudget/backend/db/repository/AuthConfigRepository.scala b/backend/src/main/scala/ssbudget/backend/db/repository/AuthConfigRepository.scala new file mode 100644 index 0000000..f4ed0d0 --- /dev/null +++ b/backend/src/main/scala/ssbudget/backend/db/repository/AuthConfigRepository.scala @@ -0,0 +1,37 @@ +package ssbudget.backend.db.repository + +import cats.effect.IO +import doobie.* +import doobie.implicits.* +import ssbudget.backend.db.DoobieMeta.given + +import java.time.Instant + +final case class AuthConfig( + passwordHash: Option[String], + createdAt: Instant, + updatedAt: Instant, +) + +trait AuthConfigRepository { + def get: IO[Option[AuthConfig]] + def upsert(passwordHash: String): IO[Unit] +} + +class AuthConfigRepositoryImpl(xa: Transactor[IO]) extends AuthConfigRepository { + + override def get: IO[Option[AuthConfig]] = { + sql""" + SELECT password_hash, created_at, updated_at FROM auth_config WHERE id = 1 + """.query[AuthConfig].option.transact(xa) + } + + override def upsert(passwordHash: String): IO[Unit] = { + val now = Instant.now() + sql""" + INSERT INTO auth_config (id, password_hash, created_at, updated_at) + VALUES (1, $passwordHash, $now, $now) + ON CONFLICT(id) DO UPDATE SET password_hash = $passwordHash, updated_at = $now + """.update.run.transact(xa).void + } +} diff --git a/backend/src/main/scala/ssbudget/backend/db/repository/BalanceSnapshotRepository.scala b/backend/src/main/scala/ssbudget/backend/db/repository/BalanceSnapshotRepository.scala new file mode 100644 index 0000000..f165ad3 --- /dev/null +++ b/backend/src/main/scala/ssbudget/backend/db/repository/BalanceSnapshotRepository.scala @@ -0,0 +1,72 @@ +package ssbudget.backend.db.repository + +import cats.effect.IO +import doobie.* +import doobie.implicits.* +import ssbudget.backend.db.DoobieMeta.given +import ssbudget.shared.model.* + +trait BalanceSnapshotRepository { + def create(snapshot: BalanceSnapshot): IO[Unit] + def findById(id: BalanceSnapshotId): IO[Option[BalanceSnapshot]] + def findByAccount(accountId: AccountId): IO[List[BalanceSnapshot]] + def findLatestByAccount(accountId: AccountId): IO[Option[BalanceSnapshot]] + def findAllLatest: IO[List[BalanceSnapshot]] + def delete(id: BalanceSnapshotId): IO[Unit] + def deleteByAccountId(accountId: AccountId): IO[Unit] +} + +class BalanceSnapshotRepositoryImpl(xa: Transactor[IO]) extends BalanceSnapshotRepository { + + override def create(snapshot: BalanceSnapshot): IO[Unit] = { + sql""" + INSERT INTO balance_snapshots (id, account_id, amount, currency, recorded_at) + VALUES (${snapshot.id}, ${snapshot.accountId}, ${snapshot.amount}, ${snapshot.currency}, ${snapshot.recordedAt}) + """.update.run.transact(xa).void + } + + override def findById(id: BalanceSnapshotId): IO[Option[BalanceSnapshot]] = { + sql""" + SELECT id, account_id, amount, currency, recorded_at + FROM balance_snapshots WHERE id = $id + """.query[BalanceSnapshot].option.transact(xa) + } + + override def findByAccount(accountId: AccountId): IO[List[BalanceSnapshot]] = { + sql""" + SELECT id, account_id, amount, currency, recorded_at + FROM balance_snapshots WHERE account_id = $accountId ORDER BY recorded_at DESC + """.query[BalanceSnapshot].to[List].transact(xa) + } + + override def findLatestByAccount(accountId: AccountId): IO[Option[BalanceSnapshot]] = { + sql""" + SELECT id, account_id, amount, currency, recorded_at + FROM balance_snapshots WHERE account_id = $accountId ORDER BY recorded_at DESC LIMIT 1 + """.query[BalanceSnapshot].option.transact(xa) + } + + override def findAllLatest: IO[List[BalanceSnapshot]] = { + sql""" + SELECT b.id, b.account_id, b.amount, b.currency, b.recorded_at + FROM balance_snapshots b + INNER JOIN ( + SELECT account_id, MAX(recorded_at) as max_recorded + FROM balance_snapshots + GROUP BY account_id + ) latest ON b.account_id = latest.account_id AND b.recorded_at = latest.max_recorded + """.query[BalanceSnapshot].to[List].transact(xa) + } + + override def delete(id: BalanceSnapshotId): IO[Unit] = { + sql""" + DELETE FROM balance_snapshots WHERE id = $id + """.update.run.transact(xa).void + } + + override def deleteByAccountId(accountId: AccountId): IO[Unit] = { + sql""" + DELETE FROM balance_snapshots WHERE account_id = $accountId + """.update.run.transact(xa).void + } +} diff --git a/backend/src/main/scala/ssbudget/backend/db/repository/CurrencySettingsRepository.scala b/backend/src/main/scala/ssbudget/backend/db/repository/CurrencySettingsRepository.scala new file mode 100644 index 0000000..cf3732d --- /dev/null +++ b/backend/src/main/scala/ssbudget/backend/db/repository/CurrencySettingsRepository.scala @@ -0,0 +1,66 @@ +package ssbudget.backend.db.repository + +import cats.effect.IO +import doobie.* +import doobie.implicits.* +import ssbudget.backend.db.DoobieMeta.given +import ssbudget.shared.model.* + +trait CurrencySettingsRepository { + def findAll: IO[List[CurrencySetting]] + def findByCode(code: String): IO[Option[CurrencySetting]] + def findPrimary: IO[Option[CurrencySetting]] + def create(setting: CurrencySetting): IO[Unit] + def setPrimary(code: String): IO[Unit] + def delete(code: String): IO[Unit] +} + +class CurrencySettingsRepositoryImpl(xa: Transactor[IO]) extends CurrencySettingsRepository { + + override def findAll: IO[List[CurrencySetting]] = { + sql""" + SELECT code, name, is_primary, enabled_at + FROM currency_settings + ORDER BY is_primary DESC, code ASC + """.query[CurrencySetting].to[List].transact(xa) + } + + override def findByCode(code: String): IO[Option[CurrencySetting]] = { + sql""" + SELECT code, name, is_primary, enabled_at + FROM currency_settings + WHERE code = $code + """.query[CurrencySetting].option.transact(xa) + } + + override def findPrimary: IO[Option[CurrencySetting]] = { + sql""" + SELECT code, name, is_primary, enabled_at + FROM currency_settings + WHERE is_primary = 1 + """.query[CurrencySetting].option.transact(xa) + } + + override def create(setting: CurrencySetting): IO[Unit] = { + sql""" + INSERT INTO currency_settings (code, name, is_primary, enabled_at) + VALUES (${setting.code}, ${setting.name}, ${setting.isPrimary}, ${setting.enabledAt}) + """.update.run.transact(xa).void + } + + override def setPrimary(code: String): IO[Unit] = { + // Transaction: clear all primary flags, then set the new one + val ops = for { + _ <- sql"UPDATE currency_settings SET is_primary = 0".update.run + _ <- sql"UPDATE currency_settings SET is_primary = 1 WHERE code = $code".update.run + } yield () + + ops.transact(xa) + } + + override def delete(code: String): IO[Unit] = { + sql""" + DELETE FROM currency_settings WHERE code = $code + """.update.run.transact(xa).void + } +} diff --git a/backend/src/main/scala/ssbudget/backend/db/repository/ExchangeRateRepository.scala b/backend/src/main/scala/ssbudget/backend/db/repository/ExchangeRateRepository.scala new file mode 100644 index 0000000..cf02f44 --- /dev/null +++ b/backend/src/main/scala/ssbudget/backend/db/repository/ExchangeRateRepository.scala @@ -0,0 +1,39 @@ +package ssbudget.backend.db.repository + +import cats.effect.IO +import doobie.* +import doobie.implicits.* +import ssbudget.backend.db.DoobieMeta.given +import ssbudget.shared.model.* + +trait ExchangeRateRepository { + def create(rate: ExchangeRate): IO[Unit] + def findLatest(from: Currency, to: Currency): IO[Option[ExchangeRate]] + def findAll: IO[List[ExchangeRate]] +} + +class ExchangeRateRepositoryImpl(xa: Transactor[IO]) extends ExchangeRateRepository { + + override def create(rate: ExchangeRate): IO[Unit] = { + sql""" + INSERT INTO exchange_rates (from_currency, to_currency, rate, fetched_at) + VALUES (${rate.fromCurrency}, ${rate.toCurrency}, ${rate.rate}, ${rate.fetchedAt}) + """.update.run.transact(xa).void + } + + override def findLatest(from: Currency, to: Currency): IO[Option[ExchangeRate]] = { + sql""" + SELECT from_currency, to_currency, rate, fetched_at + FROM exchange_rates + WHERE from_currency = $from AND to_currency = $to + ORDER BY fetched_at DESC LIMIT 1 + """.query[ExchangeRate].option.transact(xa) + } + + override def findAll: IO[List[ExchangeRate]] = { + sql""" + SELECT from_currency, to_currency, rate, fetched_at + FROM exchange_rates ORDER BY fetched_at DESC + """.query[ExchangeRate].to[List].transact(xa) + } +} diff --git a/backend/src/main/scala/ssbudget/backend/db/repository/ExpenseDefinitionRepository.scala b/backend/src/main/scala/ssbudget/backend/db/repository/ExpenseDefinitionRepository.scala new file mode 100644 index 0000000..4822820 --- /dev/null +++ b/backend/src/main/scala/ssbudget/backend/db/repository/ExpenseDefinitionRepository.scala @@ -0,0 +1,63 @@ +package ssbudget.backend.db.repository + +import cats.effect.IO +import doobie.* +import doobie.implicits.* +import ssbudget.backend.db.DoobieMeta.given +import ssbudget.shared.model.* + +trait ExpenseDefinitionRepository { + def create(expense: BudgetItemDefinition): IO[Unit] + def findById(id: ExpenseDefId): IO[Option[BudgetItemDefinition]] + def findAll: IO[List[BudgetItemDefinition]] + def findByType(itemType: BudgetItemType): IO[List[BudgetItemDefinition]] + def update(expense: BudgetItemDefinition): IO[Unit] + def delete(id: ExpenseDefId): IO[Unit] +} + +class ExpenseDefinitionRepositoryImpl(xa: Transactor[IO]) extends ExpenseDefinitionRepository { + + override def create(expense: BudgetItemDefinition): IO[Unit] = { + sql""" + INSERT INTO expense_definitions (id, name, item_type, estimate_mode, fixed_estimate, currency) + VALUES (${expense.id}, ${expense.name}, ${expense.itemType}, ${expense.estimateMode}, ${expense.fixedEstimate}, ${expense.currency}) + """.update.run.transact(xa).void + } + + override def findById(id: ExpenseDefId): IO[Option[BudgetItemDefinition]] = { + sql""" + SELECT id, name, item_type, estimate_mode, fixed_estimate, currency + FROM expense_definitions WHERE id = $id + """.query[BudgetItemDefinition].option.transact(xa) + } + + override def findAll: IO[List[BudgetItemDefinition]] = { + sql""" + SELECT id, name, item_type, estimate_mode, fixed_estimate, currency + FROM expense_definitions ORDER BY name + """.query[BudgetItemDefinition].to[List].transact(xa) + } + + override def findByType(itemType: BudgetItemType): IO[List[BudgetItemDefinition]] = { + sql""" + SELECT id, name, item_type, estimate_mode, fixed_estimate, currency + FROM expense_definitions WHERE item_type = $itemType ORDER BY name + """.query[BudgetItemDefinition].to[List].transact(xa) + } + + override def update(expense: BudgetItemDefinition): IO[Unit] = { + sql""" + UPDATE expense_definitions + SET name = ${expense.name}, item_type = ${expense.itemType}, + estimate_mode = ${expense.estimateMode}, fixed_estimate = ${expense.fixedEstimate}, + currency = ${expense.currency} + WHERE id = ${expense.id} + """.update.run.transact(xa).void + } + + override def delete(id: ExpenseDefId): IO[Unit] = { + sql""" + DELETE FROM expense_definitions WHERE id = $id + """.update.run.transact(xa).void + } +} diff --git a/backend/src/main/scala/ssbudget/backend/db/repository/ExpenseRecordRepository.scala b/backend/src/main/scala/ssbudget/backend/db/repository/ExpenseRecordRepository.scala new file mode 100644 index 0000000..9234580 --- /dev/null +++ b/backend/src/main/scala/ssbudget/backend/db/repository/ExpenseRecordRepository.scala @@ -0,0 +1,61 @@ +package ssbudget.backend.db.repository + +import cats.effect.IO +import doobie.* +import doobie.implicits.* +import ssbudget.backend.db.DoobieMeta.given +import ssbudget.shared.model.* + +import java.time.Instant + +trait ExpenseRecordRepository { + def create(record: ExpenseRecord): IO[Unit] + def findById(id: ExpenseRecordId): IO[Option[ExpenseRecord]] + def findByPeriod(periodId: PeriodId): IO[List[ExpenseRecord]] + def findByPeriodAndExpense(periodId: PeriodId, expenseDefId: ExpenseDefId): IO[Option[ExpenseRecord]] + def markAsPaid(id: ExpenseRecordId, amount: Long, paidAt: Instant): IO[Unit] + def delete(id: ExpenseRecordId): IO[Unit] +} + +class ExpenseRecordRepositoryImpl(xa: Transactor[IO]) extends ExpenseRecordRepository { + + override def create(record: ExpenseRecord): IO[Unit] = { + sql""" + INSERT INTO expense_records (id, period_id, expense_def_id, paid_amount, paid_at) + VALUES (${record.id}, ${record.periodId}, ${record.expenseDefId}, ${record.paidAmount}, ${record.paidAt}) + """.update.run.transact(xa).void + } + + override def findById(id: ExpenseRecordId): IO[Option[ExpenseRecord]] = { + sql""" + SELECT id, period_id, expense_def_id, paid_amount, paid_at + FROM expense_records WHERE id = $id + """.query[ExpenseRecord].option.transact(xa) + } + + override def findByPeriod(periodId: PeriodId): IO[List[ExpenseRecord]] = { + sql""" + SELECT id, period_id, expense_def_id, paid_amount, paid_at + FROM expense_records WHERE period_id = $periodId + """.query[ExpenseRecord].to[List].transact(xa) + } + + override def findByPeriodAndExpense(periodId: PeriodId, expenseDefId: ExpenseDefId): IO[Option[ExpenseRecord]] = { + sql""" + SELECT id, period_id, expense_def_id, paid_amount, paid_at + FROM expense_records WHERE period_id = $periodId AND expense_def_id = $expenseDefId + """.query[ExpenseRecord].option.transact(xa) + } + + override def markAsPaid(id: ExpenseRecordId, amount: Long, paidAt: Instant): IO[Unit] = { + sql""" + UPDATE expense_records SET paid_amount = $amount, paid_at = $paidAt WHERE id = $id + """.update.run.transact(xa).void + } + + override def delete(id: ExpenseRecordId): IO[Unit] = { + sql""" + DELETE FROM expense_records WHERE id = $id + """.update.run.transact(xa).void + } +} diff --git a/backend/src/main/scala/ssbudget/backend/db/repository/PasskeyCredentialRepository.scala b/backend/src/main/scala/ssbudget/backend/db/repository/PasskeyCredentialRepository.scala new file mode 100644 index 0000000..aa486d6 --- /dev/null +++ b/backend/src/main/scala/ssbudget/backend/db/repository/PasskeyCredentialRepository.scala @@ -0,0 +1,69 @@ +package ssbudget.backend.db.repository + +import cats.effect.IO +import doobie.* +import doobie.implicits.* +import ssbudget.backend.db.DoobieMeta.given + +import java.time.Instant + +final case class PasskeyCredential( + credentialId: String, + publicKeyCose: Array[Byte], + signCount: Long, + displayName: Option[String], + createdAt: Instant, + lastUsedAt: Option[Instant], +) + +trait PasskeyCredentialRepository { + def create(credential: PasskeyCredential): IO[Unit] + def findById(credentialId: String): IO[Option[PasskeyCredential]] + def findAll: IO[List[PasskeyCredential]] + def updateSignCount(credentialId: String, signCount: Long, lastUsedAt: Instant): IO[Unit] + def delete(credentialId: String): IO[Unit] + def count: IO[Int] +} + +class PasskeyCredentialRepositoryImpl(xa: Transactor[IO]) extends PasskeyCredentialRepository { + + override def create(credential: PasskeyCredential): IO[Unit] = { + sql""" + INSERT INTO passkey_credentials (credential_id, public_key_cose, sign_count, display_name, created_at, last_used_at) + VALUES (${credential.credentialId}, ${credential.publicKeyCose}, ${credential.signCount}, ${credential.displayName}, ${credential.createdAt}, ${credential.lastUsedAt}) + """.update.run.transact(xa).void + } + + override def findById(credentialId: String): IO[Option[PasskeyCredential]] = { + sql""" + SELECT credential_id, public_key_cose, sign_count, display_name, created_at, last_used_at + FROM passkey_credentials WHERE credential_id = $credentialId + """.query[PasskeyCredential].option.transact(xa) + } + + override def findAll: IO[List[PasskeyCredential]] = { + sql""" + SELECT credential_id, public_key_cose, sign_count, display_name, created_at, last_used_at + FROM passkey_credentials ORDER BY created_at DESC + """.query[PasskeyCredential].to[List].transact(xa) + } + + override def updateSignCount(credentialId: String, signCount: Long, lastUsedAt: Instant): IO[Unit] = { + sql""" + UPDATE passkey_credentials SET sign_count = $signCount, last_used_at = $lastUsedAt + WHERE credential_id = $credentialId + """.update.run.transact(xa).void + } + + override def delete(credentialId: String): IO[Unit] = { + sql""" + DELETE FROM passkey_credentials WHERE credential_id = $credentialId + """.update.run.transact(xa).void + } + + override def count: IO[Int] = { + sql""" + SELECT COUNT(*) FROM passkey_credentials + """.query[Int].unique.transact(xa) + } +} diff --git a/backend/src/main/scala/ssbudget/backend/db/repository/PeriodRepository.scala b/backend/src/main/scala/ssbudget/backend/db/repository/PeriodRepository.scala new file mode 100644 index 0000000..62b1cb3 --- /dev/null +++ b/backend/src/main/scala/ssbudget/backend/db/repository/PeriodRepository.scala @@ -0,0 +1,58 @@ +package ssbudget.backend.db.repository + +import cats.effect.IO +import doobie.* +import doobie.implicits.* +import ssbudget.backend.db.DoobieMeta.given +import ssbudget.shared.model.* + +import java.time.Instant + +trait PeriodRepository { + def create(period: Period): IO[Unit] + def findById(id: PeriodId): IO[Option[Period]] + def findCurrent: IO[Option[Period]] + def findAll: IO[List[Period]] + def close(id: PeriodId, endedAt: Instant): IO[Unit] + def delete(id: PeriodId): IO[Unit] +} + +class PeriodRepositoryImpl(xa: Transactor[IO]) extends PeriodRepository { + + override def create(period: Period): IO[Unit] = { + sql""" + INSERT INTO periods (id, started_at, ended_at) + VALUES (${period.id}, ${period.startDate}, ${period.endDate}) + """.update.run.transact(xa).void + } + + override def findById(id: PeriodId): IO[Option[Period]] = { + sql""" + SELECT id, started_at, ended_at FROM periods WHERE id = $id + """.query[Period].option.transact(xa) + } + + override def findCurrent: IO[Option[Period]] = { + sql""" + SELECT id, started_at, ended_at FROM periods WHERE ended_at IS NULL LIMIT 1 + """.query[Period].option.transact(xa) + } + + override def findAll: IO[List[Period]] = { + sql""" + SELECT id, started_at, ended_at FROM periods ORDER BY started_at DESC + """.query[Period].to[List].transact(xa) + } + + override def close(id: PeriodId, endedAt: Instant): IO[Unit] = { + sql""" + UPDATE periods SET ended_at = $endedAt WHERE id = $id + """.update.run.transact(xa).void + } + + override def delete(id: PeriodId): IO[Unit] = { + sql""" + DELETE FROM periods WHERE id = $id + """.update.run.transact(xa).void + } +} diff --git a/backend/src/main/scala/ssbudget/backend/db/repository/SavingsAccountRepository.scala b/backend/src/main/scala/ssbudget/backend/db/repository/SavingsAccountRepository.scala new file mode 100644 index 0000000..e360e94 --- /dev/null +++ b/backend/src/main/scala/ssbudget/backend/db/repository/SavingsAccountRepository.scala @@ -0,0 +1,68 @@ +package ssbudget.backend.db.repository + +import cats.effect.IO +import doobie.* +import doobie.implicits.* +import ssbudget.backend.db.DoobieMeta.given +import ssbudget.shared.model.* + +trait SavingsAccountRepository { + def create(account: SavingsAccount): IO[Unit] + def findById(id: SavingsAccountId): IO[Option[SavingsAccount]] + def findAll: IO[List[SavingsAccount]] + def update(account: SavingsAccount): IO[Unit] + def updateBalance(id: SavingsAccountId, newBalance: Long): IO[Unit] + def delete(id: SavingsAccountId): IO[Unit] + def existsWithCurrency(currency: Currency): IO[Boolean] +} + +class SavingsAccountRepositoryImpl(xa: Transactor[IO]) extends SavingsAccountRepository { + + override def create(account: SavingsAccount): IO[Unit] = { + sql""" + INSERT INTO savings_accounts (id, name, currency, current_balance, planned_monthly) + VALUES (${account.id}, ${account.name}, ${account.currency}, ${account.currentBalance}, ${account.plannedMonthly}) + """.update.run.transact(xa).void + } + + override def findById(id: SavingsAccountId): IO[Option[SavingsAccount]] = { + sql""" + SELECT id, name, currency, current_balance, planned_monthly + FROM savings_accounts WHERE id = $id + """.query[SavingsAccount].option.transact(xa) + } + + override def findAll: IO[List[SavingsAccount]] = { + sql""" + SELECT id, name, currency, current_balance, planned_monthly + FROM savings_accounts ORDER BY name + """.query[SavingsAccount].to[List].transact(xa) + } + + override def update(account: SavingsAccount): IO[Unit] = { + sql""" + UPDATE savings_accounts + SET name = ${account.name}, currency = ${account.currency}, + current_balance = ${account.currentBalance}, planned_monthly = ${account.plannedMonthly} + WHERE id = ${account.id} + """.update.run.transact(xa).void + } + + override def updateBalance(id: SavingsAccountId, newBalance: Long): IO[Unit] = { + sql""" + UPDATE savings_accounts SET current_balance = $newBalance WHERE id = $id + """.update.run.transact(xa).void + } + + override def delete(id: SavingsAccountId): IO[Unit] = { + sql""" + DELETE FROM savings_accounts WHERE id = $id + """.update.run.transact(xa).void + } + + override def existsWithCurrency(currency: Currency): IO[Boolean] = { + sql""" + SELECT EXISTS(SELECT 1 FROM savings_accounts WHERE currency = $currency) + """.query[Boolean].unique.transact(xa) + } +} diff --git a/backend/src/main/scala/ssbudget/backend/db/repository/SavingsTransactionRepository.scala b/backend/src/main/scala/ssbudget/backend/db/repository/SavingsTransactionRepository.scala new file mode 100644 index 0000000..e9ec9f0 --- /dev/null +++ b/backend/src/main/scala/ssbudget/backend/db/repository/SavingsTransactionRepository.scala @@ -0,0 +1,81 @@ +package ssbudget.backend.db.repository + +import cats.effect.IO +import doobie.* +import doobie.implicits.* +import ssbudget.backend.db.DoobieMeta.given +import ssbudget.shared.model.* + +trait SavingsTransactionRepository { + def create(transaction: SavingsTransaction): IO[Unit] + def findById(id: SavingsTransactionId): IO[Option[SavingsTransaction]] + def findByAccountId(accountId: SavingsAccountId): IO[List[SavingsTransaction]] + def findByPeriodId(periodId: PeriodId): IO[List[SavingsTransaction]] + def findByAccountAndPeriod(accountId: SavingsAccountId, periodId: PeriodId): IO[List[SavingsTransaction]] + def update(transaction: SavingsTransaction): IO[Unit] + def delete(id: SavingsTransactionId): IO[Unit] + def deleteByAccountId(accountId: SavingsAccountId): IO[Unit] +} + +class SavingsTransactionRepositoryImpl(xa: Transactor[IO]) extends SavingsTransactionRepository { + + override def create(transaction: SavingsTransaction): IO[Unit] = { + sql""" + INSERT INTO savings_transactions (id, account_id, period_id, amount, note, created_at) + VALUES (${transaction.id}, ${transaction.accountId}, ${transaction.periodId}, + ${transaction.amount}, ${transaction.note}, ${transaction.createdAt}) + """.update.run.transact(xa).void + } + + override def findById(id: SavingsTransactionId): IO[Option[SavingsTransaction]] = { + sql""" + SELECT id, account_id, period_id, amount, note, created_at + FROM savings_transactions WHERE id = $id + """.query[SavingsTransaction].option.transact(xa) + } + + override def findByAccountId(accountId: SavingsAccountId): IO[List[SavingsTransaction]] = { + sql""" + SELECT id, account_id, period_id, amount, note, created_at + FROM savings_transactions WHERE account_id = $accountId + ORDER BY created_at DESC + """.query[SavingsTransaction].to[List].transact(xa) + } + + override def findByPeriodId(periodId: PeriodId): IO[List[SavingsTransaction]] = { + sql""" + SELECT id, account_id, period_id, amount, note, created_at + FROM savings_transactions WHERE period_id = $periodId + ORDER BY created_at DESC + """.query[SavingsTransaction].to[List].transact(xa) + } + + override def findByAccountAndPeriod(accountId: SavingsAccountId, periodId: PeriodId): IO[List[SavingsTransaction]] = { + sql""" + SELECT id, account_id, period_id, amount, note, created_at + FROM savings_transactions WHERE account_id = $accountId AND period_id = $periodId + ORDER BY created_at DESC + """.query[SavingsTransaction].to[List].transact(xa) + } + + override def update(transaction: SavingsTransaction): IO[Unit] = { + sql""" + UPDATE savings_transactions + SET account_id = ${transaction.accountId}, period_id = ${transaction.periodId}, + amount = ${transaction.amount}, note = ${transaction.note}, created_at = ${transaction.createdAt} + WHERE id = ${transaction.id} + """.update.run.transact(xa).void + } + + override def delete(id: SavingsTransactionId): IO[Unit] = { + sql""" + DELETE FROM savings_transactions WHERE id = $id + """.update.run.transact(xa).void + } + + override def deleteByAccountId(accountId: SavingsAccountId): IO[Unit] = { + sql""" + DELETE FROM savings_transactions WHERE account_id = $accountId + """.update.run.transact(xa).void + } +} diff --git a/backend/src/main/scala/ssbudget/backend/db/repository/SessionRepository.scala b/backend/src/main/scala/ssbudget/backend/db/repository/SessionRepository.scala new file mode 100644 index 0000000..b770319 --- /dev/null +++ b/backend/src/main/scala/ssbudget/backend/db/repository/SessionRepository.scala @@ -0,0 +1,57 @@ +package ssbudget.backend.db.repository + +import cats.effect.IO +import doobie.* +import doobie.implicits.* +import ssbudget.backend.db.DoobieMeta.given + +import java.time.Instant + +final case class Session( + token: String, + createdAt: Instant, + expiresAt: Instant, + lastUsedAt: Instant, +) + +trait SessionRepository { + def create(session: Session): IO[Unit] + def findByToken(token: String): IO[Option[Session]] + def updateLastUsed(token: String, lastUsedAt: Instant): IO[Unit] + def delete(token: String): IO[Unit] + def deleteExpired(now: Instant): IO[Int] +} + +class SessionRepositoryImpl(xa: Transactor[IO]) extends SessionRepository { + + override def create(session: Session): IO[Unit] = { + sql""" + INSERT INTO sessions (token, created_at, expires_at, last_used_at) + VALUES (${session.token}, ${session.createdAt}, ${session.expiresAt}, ${session.lastUsedAt}) + """.update.run.transact(xa).void + } + + override def findByToken(token: String): IO[Option[Session]] = { + sql""" + SELECT token, created_at, expires_at, last_used_at FROM sessions WHERE token = $token + """.query[Session].option.transact(xa) + } + + override def updateLastUsed(token: String, lastUsedAt: Instant): IO[Unit] = { + sql""" + UPDATE sessions SET last_used_at = $lastUsedAt WHERE token = $token + """.update.run.transact(xa).void + } + + override def delete(token: String): IO[Unit] = { + sql""" + DELETE FROM sessions WHERE token = $token + """.update.run.transact(xa).void + } + + override def deleteExpired(now: Instant): IO[Int] = { + sql""" + DELETE FROM sessions WHERE expires_at < $now + """.update.run.transact(xa) + } +} diff --git a/backend/src/main/scala/ssbudget/backend/service/CurrencyService.scala b/backend/src/main/scala/ssbudget/backend/service/CurrencyService.scala new file mode 100644 index 0000000..a401dca --- /dev/null +++ b/backend/src/main/scala/ssbudget/backend/service/CurrencyService.scala @@ -0,0 +1,130 @@ +package ssbudget.backend.service + +import cats.effect.IO +import cats.implicits.* +import io.circe.generic.auto.* +import io.circe.parser.decode +import ssbudget.backend.db.Repositories +import ssbudget.shared.api.{CurrencySettingsResponse, ExchangeRatesResponse, KnownCurrency} +import ssbudget.shared.model.{Currency, CurrencySetting, ExchangeRate} +import sttp.client3.* +import sttp.client3.httpclient.cats.HttpClientCatsBackend + +import java.time.Instant + +class CurrencyService(repos: Repositories, sttpBackend: SttpBackend[IO, Any]) { + + def getSettings(): IO[CurrencySettingsResponse] = { + repos.currencySettings.findAll.map { currencies => + val available = Currency.knownCurrencies.map { case (code, name) => KnownCurrency(code, name) } + CurrencySettingsResponse( + currencies = currencies, + availableCurrencies = available, + ) + } + } + + def enableCurrency(code: String): IO[Either[String, CurrencySetting]] = { + if !Currency.isKnown(code) then { + IO.pure(Left(s"Unknown currency code: $code")) + } else { + repos.currencySettings.findByCode(code).flatMap { + case Some(existing) => IO.pure(Right(existing)) + case None => + val name = Currency.nameFor(code).getOrElse(code) + val setting = CurrencySetting(Currency(code), name, isPrimary = false, Instant.now()) + repos.currencySettings.create(setting).as(Right(setting)) + } + } + } + + def disableCurrency(code: String): IO[Either[String, Unit]] = { + for { + settingOpt <- repos.currencySettings.findByCode(code) + result <- settingOpt match { + case None => IO.pure(Left(s"Currency not found: $code")) + case Some(setting) if setting.isPrimary => IO.pure(Left("Cannot disable primary currency")) + case Some(_) => + // Check if currency is in use + val currency = Currency(code) + for { + accountsUse <- repos.accounts.existsWithCurrency(currency) + savingsUse <- repos.savingsAccounts.existsWithCurrency(currency) + result <- if accountsUse || savingsUse then { + IO.pure(Left(s"Currency $code is in use by accounts and cannot be disabled")) + } else { + repos.currencySettings.delete(code).as(Right(())) + } + } yield result + } + } yield result + } + + def setPrimaryCurrency(code: String): IO[Either[String, Unit]] = { + repos.currencySettings.findByCode(code).flatMap { + case None => IO.pure(Left(s"Currency not enabled: $code")) + case Some(_) => repos.currencySettings.setPrimary(code).as(Right(())) + } + } + + def refreshRates(): IO[Either[String, ExchangeRatesResponse]] = { + for { + primaryOpt <- repos.currencySettings.findPrimary + result <- primaryOpt match { + case None => IO.pure(Left("No primary currency configured")) + case Some(primary) => + val baseCurrency = primary.code.code + fetchRatesFromFrankfurter(baseCurrency).flatMap { + case Left(error) => IO.pure(Left(error)) + case Right(rates) => + // Store exchange rates for each currency pair + // Frankfurter returns rates like { "EUR": 0.24 } meaning 1 PLN = 0.24 EUR + // We need to store the inverse: 1 EUR = 4.17 PLN (rate to convert TO primary) + val now = Instant.now() + rates.rates.toList + .traverse { case (otherCurrency, apiRate) => + val inverseRate = if apiRate != 0 then 1.0 / apiRate else 0.0 + val exchangeRate = ExchangeRate.fromDouble( + Currency(otherCurrency), + Currency(baseCurrency), + inverseRate, + now, + ) + repos.exchangeRates.create(exchangeRate) + } + .as(Right(rates)) + } + } + } yield result + } + + private case class FrankfurterResponse( + base: String, + date: String, + rates: Map[String, Double], + ) + + private def fetchRatesFromFrankfurter(baseCurrency: String): IO[Either[String, ExchangeRatesResponse]] = { + val request = basicRequest + .get(uri"https://api.frankfurter.dev/v1/latest?base=$baseCurrency") + .response(asString) + + sttpBackend.send(request).map { response => + response.body match { + case Left(error) => Left(s"Failed to fetch rates: $error") + case Right(body) => + decode[FrankfurterResponse](body) match { + case Left(error) => Left(s"Failed to parse response: ${error.getMessage}") + case Right(response) => + Right( + ExchangeRatesResponse( + rates = response.rates, + baseCurrency = response.base, + fetchedAt = Instant.now(), + ), + ) + } + } + } + } +} diff --git a/backend/src/test/scala/ssbudget/backend/db/repository/AccountRepositorySpec.scala b/backend/src/test/scala/ssbudget/backend/db/repository/AccountRepositorySpec.scala new file mode 100644 index 0000000..c62b6e9 --- /dev/null +++ b/backend/src/test/scala/ssbudget/backend/db/repository/AccountRepositorySpec.scala @@ -0,0 +1,62 @@ +package ssbudget.backend.db.repository + +import cats.effect.IO +import ssbudget.shared.model.* + +class AccountRepositorySpec extends RepositorySpec { + + "create and findById returns the account" in { + val repo = new AccountRepositoryImpl(xa) + val account = Account(AccountId("acc-1"), "Main Account", Currency.PLN) + + for { + _ <- repo.create(account) + found <- repo.findById(AccountId("acc-1")) + } yield found shouldBe Some(account) + } + + "findById returns None for non-existent account" in { + val repo = new AccountRepositoryImpl(xa) + + for { + found <- repo.findById(AccountId("non-existent")) + } yield found shouldBe None + } + + "findAll returns all accounts ordered by name" in { + val repo = new AccountRepositoryImpl(xa) + val acc1 = Account(AccountId("acc-1"), "Zebra", Currency.PLN) + val acc2 = Account(AccountId("acc-2"), "Alpha", Currency.EUR) + val acc3 = Account(AccountId("acc-3"), "Beta", Currency.PLN) + + for { + _ <- repo.create(acc1) + _ <- repo.create(acc2) + _ <- repo.create(acc3) + all <- repo.findAll + } yield all shouldBe List(acc2, acc3, acc1) + } + + "update modifies account" in { + val repo = new AccountRepositoryImpl(xa) + val account = Account(AccountId("acc-1"), "Old Name", Currency.PLN) + val updated = account.copy(name = "New Name", currency = Currency.EUR) + + for { + _ <- repo.create(account) + _ <- repo.update(updated) + found <- repo.findById(AccountId("acc-1")) + } yield found shouldBe Some(updated) + } + + "delete removes account" in { + val repo = new AccountRepositoryImpl(xa) + val account = Account(AccountId("acc-1"), "Test", Currency.PLN) + + for { + _ <- repo.create(account) + _ <- repo.delete(AccountId("acc-1")) + found <- repo.findById(AccountId("acc-1")) + } yield found shouldBe None + } +} diff --git a/backend/src/test/scala/ssbudget/backend/db/repository/BalanceSnapshotRepositorySpec.scala b/backend/src/test/scala/ssbudget/backend/db/repository/BalanceSnapshotRepositorySpec.scala new file mode 100644 index 0000000..cb786d3 --- /dev/null +++ b/backend/src/test/scala/ssbudget/backend/db/repository/BalanceSnapshotRepositorySpec.scala @@ -0,0 +1,134 @@ +package ssbudget.backend.db.repository + +import cats.effect.IO +import ssbudget.shared.model.* + +import java.time.Instant + +class BalanceSnapshotRepositorySpec extends RepositorySpec { + + private def setupAccount(accountRepo: AccountRepository): IO[Unit] = { + val account = Account(AccountId("acc-1"), "Main", Currency.PLN) + accountRepo.create(account) + } + + "create and findById returns the balance snapshot" in { + val accountRepo = new AccountRepositoryImpl(xa) + val snapshotRepo = new BalanceSnapshotRepositoryImpl(xa) + val snapshot = BalanceSnapshot( + BalanceSnapshotId("snap-1"), + AccountId("acc-1"), + 500000L, + Currency.PLN, + Instant.parse("2024-01-15T10:00:00Z"), + ) + + for { + _ <- setupAccount(accountRepo) + _ <- snapshotRepo.create(snapshot) + found <- snapshotRepo.findById(BalanceSnapshotId("snap-1")) + } yield found shouldBe Some(snapshot) + } + + "findById returns None for non-existent snapshot" in { + val snapshotRepo = new BalanceSnapshotRepositoryImpl(xa) + + for { + found <- snapshotRepo.findById(BalanceSnapshotId("non-existent")) + } yield found shouldBe None + } + + "findByAccount returns all snapshots for that account ordered by time desc" in { + val accountRepo = new AccountRepositoryImpl(xa) + val snapshotRepo = new BalanceSnapshotRepositoryImpl(xa) + val snap1 = BalanceSnapshot(BalanceSnapshotId("snap-1"), AccountId("acc-1"), 100L, Currency.PLN, Instant.parse("2024-01-10T10:00:00Z")) + val snap2 = BalanceSnapshot(BalanceSnapshotId("snap-2"), AccountId("acc-1"), 200L, Currency.PLN, Instant.parse("2024-01-15T10:00:00Z")) + val snap3 = BalanceSnapshot(BalanceSnapshotId("snap-3"), AccountId("acc-1"), 300L, Currency.PLN, Instant.parse("2024-01-12T10:00:00Z")) + + for { + _ <- setupAccount(accountRepo) + _ <- snapshotRepo.create(snap1) + _ <- snapshotRepo.create(snap2) + _ <- snapshotRepo.create(snap3) + all <- snapshotRepo.findByAccount(AccountId("acc-1")) + } yield all shouldBe List(snap2, snap3, snap1) + } + + "findLatestByAccount returns most recent snapshot" in { + val accountRepo = new AccountRepositoryImpl(xa) + val snapshotRepo = new BalanceSnapshotRepositoryImpl(xa) + val snap1 = BalanceSnapshot(BalanceSnapshotId("snap-1"), AccountId("acc-1"), 100L, Currency.PLN, Instant.parse("2024-01-10T10:00:00Z")) + val snap2 = BalanceSnapshot(BalanceSnapshotId("snap-2"), AccountId("acc-1"), 200L, Currency.PLN, Instant.parse("2024-01-15T10:00:00Z")) + + for { + _ <- setupAccount(accountRepo) + _ <- snapshotRepo.create(snap1) + _ <- snapshotRepo.create(snap2) + latest <- snapshotRepo.findLatestByAccount(AccountId("acc-1")) + } yield latest shouldBe Some(snap2) + } + + "findAllLatest returns one snapshot per account" in { + val accountRepo = new AccountRepositoryImpl(xa) + val snapshotRepo = new BalanceSnapshotRepositoryImpl(xa) + + val acc1 = Account(AccountId("acc-1"), "Main", Currency.PLN) + val acc2 = Account(AccountId("acc-2"), "Savings", Currency.EUR) + + val snap1a = BalanceSnapshot(BalanceSnapshotId("snap-1a"), AccountId("acc-1"), 100L, Currency.PLN, Instant.parse("2024-01-10T10:00:00Z")) + val snap1b = BalanceSnapshot(BalanceSnapshotId("snap-1b"), AccountId("acc-1"), 200L, Currency.PLN, Instant.parse("2024-01-15T10:00:00Z")) + val snap2a = BalanceSnapshot(BalanceSnapshotId("snap-2a"), AccountId("acc-2"), 300L, Currency.EUR, Instant.parse("2024-01-12T10:00:00Z")) + + for { + _ <- accountRepo.create(acc1) + _ <- accountRepo.create(acc2) + _ <- snapshotRepo.create(snap1a) + _ <- snapshotRepo.create(snap1b) + _ <- snapshotRepo.create(snap2a) + latest <- snapshotRepo.findAllLatest + } yield { + latest.length shouldBe 2 + latest should contain(snap1b) + latest should contain(snap2a) + } + } + + "delete removes snapshot" in { + val accountRepo = new AccountRepositoryImpl(xa) + val snapshotRepo = new BalanceSnapshotRepositoryImpl(xa) + val snapshot = BalanceSnapshot(BalanceSnapshotId("snap-1"), AccountId("acc-1"), 500000L, Currency.PLN, Instant.parse("2024-01-15T10:00:00Z")) + + for { + _ <- setupAccount(accountRepo) + _ <- snapshotRepo.create(snapshot) + _ <- snapshotRepo.delete(BalanceSnapshotId("snap-1")) + found <- snapshotRepo.findById(BalanceSnapshotId("snap-1")) + } yield found shouldBe None + } + + "deleteByAccountId removes all snapshots for that account" in { + val accountRepo = new AccountRepositoryImpl(xa) + val snapshotRepo = new BalanceSnapshotRepositoryImpl(xa) + + val acc1 = Account(AccountId("acc-1"), "Main", Currency.PLN) + val acc2 = Account(AccountId("acc-2"), "Savings", Currency.EUR) + + val snap1a = BalanceSnapshot(BalanceSnapshotId("snap-1a"), AccountId("acc-1"), 100L, Currency.PLN, Instant.parse("2024-01-10T10:00:00Z")) + val snap1b = BalanceSnapshot(BalanceSnapshotId("snap-1b"), AccountId("acc-1"), 200L, Currency.PLN, Instant.parse("2024-01-15T10:00:00Z")) + val snap2a = BalanceSnapshot(BalanceSnapshotId("snap-2a"), AccountId("acc-2"), 300L, Currency.EUR, Instant.parse("2024-01-12T10:00:00Z")) + + for { + _ <- accountRepo.create(acc1) + _ <- accountRepo.create(acc2) + _ <- snapshotRepo.create(snap1a) + _ <- snapshotRepo.create(snap1b) + _ <- snapshotRepo.create(snap2a) + _ <- snapshotRepo.deleteByAccountId(AccountId("acc-1")) + acc1Snaps <- snapshotRepo.findByAccount(AccountId("acc-1")) + acc2Snaps <- snapshotRepo.findByAccount(AccountId("acc-2")) + } yield { + acc1Snaps shouldBe empty + acc2Snaps shouldBe List(snap2a) + } + } +} diff --git a/backend/src/test/scala/ssbudget/backend/db/repository/ExchangeRateRepositorySpec.scala b/backend/src/test/scala/ssbudget/backend/db/repository/ExchangeRateRepositorySpec.scala new file mode 100644 index 0000000..d2d5d9d --- /dev/null +++ b/backend/src/test/scala/ssbudget/backend/db/repository/ExchangeRateRepositorySpec.scala @@ -0,0 +1,60 @@ +package ssbudget.backend.db.repository + +import cats.effect.IO +import ssbudget.shared.model.* + +import java.time.Instant + +class ExchangeRateRepositorySpec extends RepositorySpec { + + "create and findLatest returns the exchange rate" in { + val repo = new ExchangeRateRepositoryImpl(xa) + val rate = ExchangeRate( + Currency.EUR, + Currency.PLN, + 45000L, // 4.5 PLN/EUR + Instant.parse("2024-01-15T10:00:00Z"), + ) + + for { + _ <- repo.create(rate) + found <- repo.findLatest(Currency.EUR, Currency.PLN) + } yield found shouldBe Some(rate) + } + + "findLatest returns most recent rate for currency pair" in { + val repo = new ExchangeRateRepositoryImpl(xa) + val rate1 = ExchangeRate(Currency.EUR, Currency.PLN, 43000L, Instant.parse("2024-01-10T10:00:00Z")) + val rate2 = ExchangeRate(Currency.EUR, Currency.PLN, 45000L, Instant.parse("2024-01-15T10:00:00Z")) + val rate3 = ExchangeRate(Currency.EUR, Currency.PLN, 44000L, Instant.parse("2024-01-12T10:00:00Z")) + + for { + _ <- repo.create(rate1) + _ <- repo.create(rate2) + _ <- repo.create(rate3) + latest <- repo.findLatest(Currency.EUR, Currency.PLN) + } yield latest shouldBe Some(rate2) + } + + "findLatest returns None when no rates exist for pair" in { + val repo = new ExchangeRateRepositoryImpl(xa) + val rate = ExchangeRate(Currency.EUR, Currency.PLN, 45000L, Instant.parse("2024-01-15T10:00:00Z")) + + for { + _ <- repo.create(rate) + found <- repo.findLatest(Currency.PLN, Currency.EUR) + } yield found shouldBe None + } + + "findAll returns all rates ordered by fetched_at desc" in { + val repo = new ExchangeRateRepositoryImpl(xa) + val rate1 = ExchangeRate(Currency.EUR, Currency.PLN, 43000L, Instant.parse("2024-01-10T10:00:00Z")) + val rate2 = ExchangeRate(Currency.EUR, Currency.PLN, 45000L, Instant.parse("2024-01-15T10:00:00Z")) + + for { + _ <- repo.create(rate1) + _ <- repo.create(rate2) + all <- repo.findAll + } yield all shouldBe List(rate2, rate1) + } +} diff --git a/backend/src/test/scala/ssbudget/backend/db/repository/ExpenseDefinitionRepositorySpec.scala b/backend/src/test/scala/ssbudget/backend/db/repository/ExpenseDefinitionRepositorySpec.scala new file mode 100644 index 0000000..8f4a919 --- /dev/null +++ b/backend/src/test/scala/ssbudget/backend/db/repository/ExpenseDefinitionRepositorySpec.scala @@ -0,0 +1,91 @@ +package ssbudget.backend.db.repository + +import ssbudget.shared.model.* + +class ExpenseDefinitionRepositorySpec extends RepositorySpec { + + "create and findById returns the budget item definition" in { + val repo = new ExpenseDefinitionRepositoryImpl(xa) + val item = BudgetItemDefinition( + ExpenseDefId("exp-1"), + "Rent", + BudgetItemType.PlannedExpense, + EstimateMode.Fixed, + Some(200000L), + Currency.PLN, + ) + + for { + _ <- repo.create(item) + found <- repo.findById(ExpenseDefId("exp-1")) + } yield found shouldBe Some(item) + } + + "findById returns None for non-existent item" in { + val repo = new ExpenseDefinitionRepositoryImpl(xa) + + for { + found <- repo.findById(ExpenseDefId("non-existent")) + } yield found shouldBe None + } + + "findAll returns all budget item definitions ordered by name" in { + val repo = new ExpenseDefinitionRepositoryImpl(xa) + val exp1 = BudgetItemDefinition(ExpenseDefId("exp-1"), "Zebra", BudgetItemType.PlannedExpense, EstimateMode.Fixed, Some(100L), Currency.PLN) + val exp2 = BudgetItemDefinition(ExpenseDefId("exp-2"), "Alpha", BudgetItemType.EstimatedExpense, EstimateMode.Average, None, Currency.PLN) + val exp3 = BudgetItemDefinition(ExpenseDefId("exp-3"), "Beta", BudgetItemType.PlannedIncome, EstimateMode.LastMonth, None, Currency.PLN) + + for { + _ <- repo.create(exp1) + _ <- repo.create(exp2) + _ <- repo.create(exp3) + all <- repo.findAll + } yield all.map(_.name) shouldBe List("Alpha", "Beta", "Zebra") + } + + "findByType returns only items of that type" in { + val repo = new ExpenseDefinitionRepositoryImpl(xa) + val plannedExpense = + BudgetItemDefinition(ExpenseDefId("exp-1"), "Rent", BudgetItemType.PlannedExpense, EstimateMode.Fixed, Some(100L), Currency.PLN) + val estimatedExpense = + BudgetItemDefinition(ExpenseDefId("exp-2"), "Groceries", BudgetItemType.EstimatedExpense, EstimateMode.Average, None, Currency.PLN) + val plannedIncome = + BudgetItemDefinition(ExpenseDefId("exp-3"), "Salary", BudgetItemType.PlannedIncome, EstimateMode.Fixed, Some(500000L), Currency.PLN) + + for { + _ <- repo.create(plannedExpense) + _ <- repo.create(estimatedExpense) + _ <- repo.create(plannedIncome) + plannedExpenses <- repo.findByType(BudgetItemType.PlannedExpense) + estimatedExpenses <- repo.findByType(BudgetItemType.EstimatedExpense) + plannedIncomes <- repo.findByType(BudgetItemType.PlannedIncome) + } yield { + plannedExpenses shouldBe List(plannedExpense) + estimatedExpenses shouldBe List(estimatedExpense) + plannedIncomes shouldBe List(plannedIncome) + } + } + + "update modifies budget item definition" in { + val repo = new ExpenseDefinitionRepositoryImpl(xa) + val item = BudgetItemDefinition(ExpenseDefId("exp-1"), "Old", BudgetItemType.PlannedExpense, EstimateMode.Fixed, Some(100L), Currency.PLN) + val updated = item.copy(name = "New", fixedEstimate = Some(200L)) + + for { + _ <- repo.create(item) + _ <- repo.update(updated) + found <- repo.findById(ExpenseDefId("exp-1")) + } yield found shouldBe Some(updated) + } + + "delete removes budget item definition" in { + val repo = new ExpenseDefinitionRepositoryImpl(xa) + val item = BudgetItemDefinition(ExpenseDefId("exp-1"), "Test", BudgetItemType.PlannedExpense, EstimateMode.Fixed, None, Currency.PLN) + + for { + _ <- repo.create(item) + _ <- repo.delete(ExpenseDefId("exp-1")) + found <- repo.findById(ExpenseDefId("exp-1")) + } yield found shouldBe None + } +} diff --git a/backend/src/test/scala/ssbudget/backend/db/repository/ExpenseRecordRepositorySpec.scala b/backend/src/test/scala/ssbudget/backend/db/repository/ExpenseRecordRepositorySpec.scala new file mode 100644 index 0000000..5f623f3 --- /dev/null +++ b/backend/src/test/scala/ssbudget/backend/db/repository/ExpenseRecordRepositorySpec.scala @@ -0,0 +1,103 @@ +package ssbudget.backend.db.repository + +import cats.effect.IO +import ssbudget.shared.model.* + +import java.time.Instant + +class ExpenseRecordRepositorySpec extends RepositorySpec { + + private def setupPeriodAndExpense( + periodRepo: PeriodRepository, + expenseRepo: ExpenseDefinitionRepository, + ): IO[Unit] = { + val period = Period(PeriodId("per-1"), Instant.parse("2024-01-25T00:00:00Z"), None) + val expense = BudgetItemDefinition(ExpenseDefId("exp-1"), "Rent", BudgetItemType.PlannedExpense, EstimateMode.Fixed, Some(200000L), Currency.PLN) + periodRepo.create(period) *> expenseRepo.create(expense) + } + + "create and findById returns the expense record" in { + val periodRepo = new PeriodRepositoryImpl(xa) + val expenseRepo = new ExpenseDefinitionRepositoryImpl(xa) + val recordRepo = new ExpenseRecordRepositoryImpl(xa) + val record = ExpenseRecord(ExpenseRecordId("rec-1"), PeriodId("per-1"), ExpenseDefId("exp-1"), None, None) + + for { + _ <- setupPeriodAndExpense(periodRepo, expenseRepo) + _ <- recordRepo.create(record) + found <- recordRepo.findById(ExpenseRecordId("rec-1")) + } yield found shouldBe Some(record) + } + + "findById returns None for non-existent record" in { + val recordRepo = new ExpenseRecordRepositoryImpl(xa) + + for { + found <- recordRepo.findById(ExpenseRecordId("non-existent")) + } yield found shouldBe None + } + + "findByPeriod returns all records for that period" in { + val periodRepo = new PeriodRepositoryImpl(xa) + val expenseRepo = new ExpenseDefinitionRepositoryImpl(xa) + val recordRepo = new ExpenseRecordRepositoryImpl(xa) + + val period1 = Period(PeriodId("per-1"), Instant.parse("2024-01-25T00:00:00Z"), None) + val period2 = Period(PeriodId("per-2"), Instant.parse("2024-02-25T00:00:00Z"), None) + val expense = BudgetItemDefinition(ExpenseDefId("exp-1"), "Rent", BudgetItemType.PlannedExpense, EstimateMode.Fixed, Some(100L), Currency.PLN) + val record1 = ExpenseRecord(ExpenseRecordId("rec-1"), PeriodId("per-1"), ExpenseDefId("exp-1"), None, None) + val record2 = ExpenseRecord(ExpenseRecordId("rec-2"), PeriodId("per-2"), ExpenseDefId("exp-1"), None, None) + + for { + _ <- periodRepo.create(period1) + _ <- periodRepo.create(period2) + _ <- expenseRepo.create(expense) + _ <- recordRepo.create(record1) + _ <- recordRepo.create(record2) + period1Records <- recordRepo.findByPeriod(PeriodId("per-1")) + } yield period1Records shouldBe List(record1) + } + + "findByPeriodAndExpense returns specific record" in { + val periodRepo = new PeriodRepositoryImpl(xa) + val expenseRepo = new ExpenseDefinitionRepositoryImpl(xa) + val recordRepo = new ExpenseRecordRepositoryImpl(xa) + val record = ExpenseRecord(ExpenseRecordId("rec-1"), PeriodId("per-1"), ExpenseDefId("exp-1"), None, None) + + for { + _ <- setupPeriodAndExpense(periodRepo, expenseRepo) + _ <- recordRepo.create(record) + found <- recordRepo.findByPeriodAndExpense(PeriodId("per-1"), ExpenseDefId("exp-1")) + } yield found shouldBe Some(record) + } + + "markAsPaid updates amount and timestamp" in { + val periodRepo = new PeriodRepositoryImpl(xa) + val expenseRepo = new ExpenseDefinitionRepositoryImpl(xa) + val recordRepo = new ExpenseRecordRepositoryImpl(xa) + val record = ExpenseRecord(ExpenseRecordId("rec-1"), PeriodId("per-1"), ExpenseDefId("exp-1"), None, None) + val paidAt = Instant.parse("2024-02-01T10:00:00Z") + val paidAmount = 195000L + + for { + _ <- setupPeriodAndExpense(periodRepo, expenseRepo) + _ <- recordRepo.create(record) + _ <- recordRepo.markAsPaid(ExpenseRecordId("rec-1"), paidAmount, paidAt) + found <- recordRepo.findById(ExpenseRecordId("rec-1")) + } yield found shouldBe Some(record.copy(paidAmount = Some(paidAmount), paidAt = Some(paidAt))) + } + + "delete removes expense record" in { + val periodRepo = new PeriodRepositoryImpl(xa) + val expenseRepo = new ExpenseDefinitionRepositoryImpl(xa) + val recordRepo = new ExpenseRecordRepositoryImpl(xa) + val record = ExpenseRecord(ExpenseRecordId("rec-1"), PeriodId("per-1"), ExpenseDefId("exp-1"), None, None) + + for { + _ <- setupPeriodAndExpense(periodRepo, expenseRepo) + _ <- recordRepo.create(record) + _ <- recordRepo.delete(ExpenseRecordId("rec-1")) + found <- recordRepo.findById(ExpenseRecordId("rec-1")) + } yield found shouldBe None + } +} diff --git a/backend/src/test/scala/ssbudget/backend/db/repository/PeriodRepositorySpec.scala b/backend/src/test/scala/ssbudget/backend/db/repository/PeriodRepositorySpec.scala new file mode 100644 index 0000000..014b719 --- /dev/null +++ b/backend/src/test/scala/ssbudget/backend/db/repository/PeriodRepositorySpec.scala @@ -0,0 +1,86 @@ +package ssbudget.backend.db.repository + +import cats.effect.IO +import ssbudget.shared.model.* + +import java.time.Instant + +class PeriodRepositorySpec extends RepositorySpec { + + "create and findById returns the period" in { + val repo = new PeriodRepositoryImpl(xa) + val period = Period(PeriodId("per-1"), Instant.parse("2024-01-25T00:00:00Z"), None) + + for { + _ <- repo.create(period) + found <- repo.findById(PeriodId("per-1")) + } yield found shouldBe Some(period) + } + + "findById returns None for non-existent period" in { + val repo = new PeriodRepositoryImpl(xa) + + for { + found <- repo.findById(PeriodId("non-existent")) + } yield found shouldBe None + } + + "findCurrent returns open period (no endDate)" in { + val repo = new PeriodRepositoryImpl(xa) + val closed = Period(PeriodId("per-1"), Instant.parse("2024-01-25T00:00:00Z"), Some(Instant.parse("2024-02-24T00:00:00Z"))) + val current = Period(PeriodId("per-2"), Instant.parse("2024-02-25T00:00:00Z"), None) + + for { + _ <- repo.create(closed) + _ <- repo.create(current) + found <- repo.findCurrent + } yield found shouldBe Some(current) + } + + "findCurrent returns None when all periods are closed" in { + val repo = new PeriodRepositoryImpl(xa) + val closed = Period(PeriodId("per-1"), Instant.parse("2024-01-25T00:00:00Z"), Some(Instant.parse("2024-02-24T00:00:00Z"))) + + for { + _ <- repo.create(closed) + found <- repo.findCurrent + } yield found shouldBe None + } + + "findAll returns periods ordered by startDate descending" in { + val repo = new PeriodRepositoryImpl(xa) + val p1 = Period(PeriodId("per-1"), Instant.parse("2024-01-25T00:00:00Z"), Some(Instant.parse("2024-02-24T00:00:00Z"))) + val p2 = Period(PeriodId("per-2"), Instant.parse("2024-02-25T00:00:00Z"), Some(Instant.parse("2024-03-24T00:00:00Z"))) + val p3 = Period(PeriodId("per-3"), Instant.parse("2024-03-25T00:00:00Z"), None) + + for { + _ <- repo.create(p1) + _ <- repo.create(p2) + _ <- repo.create(p3) + all <- repo.findAll + } yield all shouldBe List(p3, p2, p1) + } + + "close sets endDate on period" in { + val repo = new PeriodRepositoryImpl(xa) + val period = Period(PeriodId("per-1"), Instant.parse("2024-01-25T00:00:00Z"), None) + val endedAt = Instant.parse("2024-02-24T00:00:00Z") + + for { + _ <- repo.create(period) + _ <- repo.close(PeriodId("per-1"), endedAt) + found <- repo.findById(PeriodId("per-1")) + } yield found shouldBe Some(period.copy(endDate = Some(endedAt))) + } + + "delete removes period" in { + val repo = new PeriodRepositoryImpl(xa) + val period = Period(PeriodId("per-1"), Instant.parse("2024-01-25T00:00:00Z"), None) + + for { + _ <- repo.create(period) + _ <- repo.delete(PeriodId("per-1")) + found <- repo.findById(PeriodId("per-1")) + } yield found shouldBe None + } +} diff --git a/backend/src/test/scala/ssbudget/backend/db/repository/RepositorySpec.scala b/backend/src/test/scala/ssbudget/backend/db/repository/RepositorySpec.scala new file mode 100644 index 0000000..a46c46a --- /dev/null +++ b/backend/src/test/scala/ssbudget/backend/db/repository/RepositorySpec.scala @@ -0,0 +1,33 @@ +package ssbudget.backend.db.repository + +import cats.effect.IO +import cats.effect.testing.scalatest.AsyncIOSpec +import cats.effect.unsafe.implicits.global +import doobie.Transactor +import org.scalatest.BeforeAndAfterEach +import org.scalatest.freespec.AsyncFreeSpec +import org.scalatest.matchers.should.Matchers +import ssbudget.backend.db.Database + +import java.util.UUID + +trait RepositorySpec extends AsyncFreeSpec with AsyncIOSpec with Matchers with BeforeAndAfterEach { + + protected var xa: Transactor[IO] = scala.compiletime.uninitialized + private var cleanup: IO[Unit] = IO.unit + + override def beforeEach(): Unit = { + // Use shared-cache mode with a unique name so each test gets its own isolated database + // that persists across multiple connections during the test + val dbName = UUID.randomUUID().toString + val jdbcUrl = s"jdbc:sqlite:file:$dbName?mode=memory&cache=shared" + val resource = Database.migrateAndTransactor(jdbcUrl) + val (transactor, release) = resource.allocated.unsafeRunSync() + xa = transactor + cleanup = release + } + + override def afterEach(): Unit = { + cleanup.unsafeRunSync() + } +} diff --git a/backend/src/test/scala/ssbudget/backend/db/repository/SavingsAccountRepositorySpec.scala b/backend/src/test/scala/ssbudget/backend/db/repository/SavingsAccountRepositorySpec.scala new file mode 100644 index 0000000..b7d737e --- /dev/null +++ b/backend/src/test/scala/ssbudget/backend/db/repository/SavingsAccountRepositorySpec.scala @@ -0,0 +1,89 @@ +package ssbudget.backend.db.repository + +import ssbudget.shared.model.* + +class SavingsAccountRepositorySpec extends RepositorySpec { + + "create and findById returns the savings account" in { + val repo = new SavingsAccountRepositoryImpl(xa) + val account = SavingsAccount(SavingsAccountId("sav-1"), "Emergency Fund", Currency.PLN, 100000, Some(50000)) + + for { + _ <- repo.create(account) + found <- repo.findById(SavingsAccountId("sav-1")) + } yield found shouldBe Some(account) + } + + "findById returns None for non-existent account" in { + val repo = new SavingsAccountRepositoryImpl(xa) + + for { + found <- repo.findById(SavingsAccountId("non-existent")) + } yield found shouldBe None + } + + "findAll returns all savings accounts ordered by name" in { + val repo = new SavingsAccountRepositoryImpl(xa) + val acc1 = SavingsAccount(SavingsAccountId("sav-1"), "Zebra Fund", Currency.PLN, 0, None) + val acc2 = SavingsAccount(SavingsAccountId("sav-2"), "Alpha Fund", Currency.EUR, 50000, Some(10000)) + val acc3 = SavingsAccount(SavingsAccountId("sav-3"), "Beta Fund", Currency.PLN, 25000, None) + + for { + _ <- repo.create(acc1) + _ <- repo.create(acc2) + _ <- repo.create(acc3) + all <- repo.findAll + } yield all shouldBe List(acc2, acc3, acc1) + } + + "update modifies savings account" in { + val repo = new SavingsAccountRepositoryImpl(xa) + val account = SavingsAccount(SavingsAccountId("sav-1"), "Old Name", Currency.PLN, 100000, None) + val updated = account.copy(name = "New Name", currentBalance = 150000, plannedMonthly = Some(25000)) + + for { + _ <- repo.create(account) + _ <- repo.update(updated) + found <- repo.findById(SavingsAccountId("sav-1")) + } yield found shouldBe Some(updated) + } + + "updateBalance modifies only the balance" in { + val repo = new SavingsAccountRepositoryImpl(xa) + val account = SavingsAccount(SavingsAccountId("sav-1"), "Fund", Currency.PLN, 100000, Some(50000)) + + for { + _ <- repo.create(account) + _ <- repo.updateBalance(SavingsAccountId("sav-1"), 200000) + found <- repo.findById(SavingsAccountId("sav-1")) + } yield { + found.map(_.currentBalance) shouldBe Some(200000) + found.map(_.name) shouldBe Some("Fund") + found.flatMap(_.plannedMonthly) shouldBe Some(50000) + } + } + + "delete removes savings account" in { + val repo = new SavingsAccountRepositoryImpl(xa) + val account = SavingsAccount(SavingsAccountId("sav-1"), "Test", Currency.PLN, 0, None) + + for { + _ <- repo.create(account) + _ <- repo.delete(SavingsAccountId("sav-1")) + found <- repo.findById(SavingsAccountId("sav-1")) + } yield found shouldBe None + } + + "handles accounts without planned monthly target" in { + val repo = new SavingsAccountRepositoryImpl(xa) + val account = SavingsAccount(SavingsAccountId("sav-1"), "No Target", Currency.EUR, 50000, None) + + for { + _ <- repo.create(account) + found <- repo.findById(SavingsAccountId("sav-1")) + } yield { + found shouldBe Some(account) + found.flatMap(_.plannedMonthly) shouldBe None + } + } +} diff --git a/backend/src/test/scala/ssbudget/backend/db/repository/SavingsTransactionRepositorySpec.scala b/backend/src/test/scala/ssbudget/backend/db/repository/SavingsTransactionRepositorySpec.scala new file mode 100644 index 0000000..4244983 --- /dev/null +++ b/backend/src/test/scala/ssbudget/backend/db/repository/SavingsTransactionRepositorySpec.scala @@ -0,0 +1,157 @@ +package ssbudget.backend.db.repository + +import cats.effect.unsafe.implicits.global +import ssbudget.shared.model.* + +import java.time.Instant + +class SavingsTransactionRepositorySpec extends RepositorySpec { + + private val now = Instant.parse("2026-01-15T10:00:00Z") + private val yesterday = Instant.parse("2026-01-14T10:00:00Z") + + private def createPrerequisites(): Unit = { + // Create savings account first (foreign key) + val accountRepo = new SavingsAccountRepositoryImpl(xa) + val periodRepo = new PeriodRepositoryImpl(xa) + + (for { + _ <- accountRepo.create(SavingsAccount(SavingsAccountId("sav-1"), "Fund 1", Currency.PLN, 0, None)) + _ <- accountRepo.create(SavingsAccount(SavingsAccountId("sav-2"), "Fund 2", Currency.EUR, 0, None)) + _ <- periodRepo.create(Period(PeriodId("period-1"), now, None)) + _ <- periodRepo.create(Period(PeriodId("period-2"), yesterday, Some(now))) + } yield ()).unsafeRunSync() + } + + "create and findById returns the transaction" in { + createPrerequisites() + val repo = new SavingsTransactionRepositoryImpl(xa) + val txn = SavingsTransaction( + SavingsTransactionId("txn-1"), + SavingsAccountId("sav-1"), + PeriodId("period-1"), + 50000, + Some("Initial deposit"), + now, + ) + + for { + _ <- repo.create(txn) + found <- repo.findById(SavingsTransactionId("txn-1")) + } yield found shouldBe Some(txn) + } + + "findById returns None for non-existent transaction" in { + createPrerequisites() + val repo = new SavingsTransactionRepositoryImpl(xa) + + for { + found <- repo.findById(SavingsTransactionId("non-existent")) + } yield found shouldBe None + } + + "findByAccountId returns transactions for account ordered by created_at desc" in { + createPrerequisites() + val repo = new SavingsTransactionRepositoryImpl(xa) + val txn1 = SavingsTransaction(SavingsTransactionId("txn-1"), SavingsAccountId("sav-1"), PeriodId("period-1"), 50000, None, yesterday) + val txn2 = SavingsTransaction(SavingsTransactionId("txn-2"), SavingsAccountId("sav-1"), PeriodId("period-1"), -10000, Some("Withdrawal"), now) + val txn3 = SavingsTransaction(SavingsTransactionId("txn-3"), SavingsAccountId("sav-2"), PeriodId("period-1"), 25000, None, now) + + for { + _ <- repo.create(txn1) + _ <- repo.create(txn2) + _ <- repo.create(txn3) + result <- repo.findByAccountId(SavingsAccountId("sav-1")) + } yield result shouldBe List(txn2, txn1) // Newest first + } + + "findByPeriodId returns transactions for period" in { + createPrerequisites() + val repo = new SavingsTransactionRepositoryImpl(xa) + val txn1 = SavingsTransaction(SavingsTransactionId("txn-1"), SavingsAccountId("sav-1"), PeriodId("period-1"), 50000, None, now) + val txn2 = SavingsTransaction(SavingsTransactionId("txn-2"), SavingsAccountId("sav-1"), PeriodId("period-2"), 30000, None, yesterday) + + for { + _ <- repo.create(txn1) + _ <- repo.create(txn2) + result <- repo.findByPeriodId(PeriodId("period-1")) + } yield result shouldBe List(txn1) + } + + "findByAccountAndPeriod returns matching transactions" in { + createPrerequisites() + val repo = new SavingsTransactionRepositoryImpl(xa) + val txn1 = SavingsTransaction(SavingsTransactionId("txn-1"), SavingsAccountId("sav-1"), PeriodId("period-1"), 50000, None, yesterday) + val txn2 = SavingsTransaction(SavingsTransactionId("txn-2"), SavingsAccountId("sav-1"), PeriodId("period-1"), -10000, None, now) + val txn3 = SavingsTransaction(SavingsTransactionId("txn-3"), SavingsAccountId("sav-1"), PeriodId("period-2"), 30000, None, now) + val txn4 = SavingsTransaction(SavingsTransactionId("txn-4"), SavingsAccountId("sav-2"), PeriodId("period-1"), 25000, None, now) + + for { + _ <- repo.create(txn1) + _ <- repo.create(txn2) + _ <- repo.create(txn3) + _ <- repo.create(txn4) + result <- repo.findByAccountAndPeriod(SavingsAccountId("sav-1"), PeriodId("period-1")) + } yield result shouldBe List(txn2, txn1) // Newest first + } + + "update modifies transaction" in { + createPrerequisites() + val repo = new SavingsTransactionRepositoryImpl(xa) + val txn = SavingsTransaction(SavingsTransactionId("txn-1"), SavingsAccountId("sav-1"), PeriodId("period-1"), 50000, None, now) + val updated = txn.copy(amount = 60000, note = Some("Updated note")) + + for { + _ <- repo.create(txn) + _ <- repo.update(updated) + found <- repo.findById(SavingsTransactionId("txn-1")) + } yield found shouldBe Some(updated) + } + + "delete removes transaction" in { + createPrerequisites() + val repo = new SavingsTransactionRepositoryImpl(xa) + val txn = SavingsTransaction(SavingsTransactionId("txn-1"), SavingsAccountId("sav-1"), PeriodId("period-1"), 50000, None, now) + + for { + _ <- repo.create(txn) + _ <- repo.delete(SavingsTransactionId("txn-1")) + found <- repo.findById(SavingsTransactionId("txn-1")) + } yield found shouldBe None + } + + "deleteByAccountId removes all transactions for account" in { + createPrerequisites() + val repo = new SavingsTransactionRepositoryImpl(xa) + val txn1 = SavingsTransaction(SavingsTransactionId("txn-1"), SavingsAccountId("sav-1"), PeriodId("period-1"), 50000, None, now) + val txn2 = SavingsTransaction(SavingsTransactionId("txn-2"), SavingsAccountId("sav-1"), PeriodId("period-2"), 30000, None, yesterday) + val txn3 = SavingsTransaction(SavingsTransactionId("txn-3"), SavingsAccountId("sav-2"), PeriodId("period-1"), 25000, None, now) + + for { + _ <- repo.create(txn1) + _ <- repo.create(txn2) + _ <- repo.create(txn3) + _ <- repo.deleteByAccountId(SavingsAccountId("sav-1")) + acc1Txn <- repo.findByAccountId(SavingsAccountId("sav-1")) + acc2Txn <- repo.findByAccountId(SavingsAccountId("sav-2")) + } yield { + acc1Txn shouldBe empty + acc2Txn shouldBe List(txn3) + } + } + + "handles negative amounts (outflows)" in { + createPrerequisites() + val repo = new SavingsTransactionRepositoryImpl(xa) + val txn = + SavingsTransaction(SavingsTransactionId("txn-1"), SavingsAccountId("sav-1"), PeriodId("period-1"), -25000, Some("Emergency withdrawal"), now) + + for { + _ <- repo.create(txn) + found <- repo.findById(SavingsTransactionId("txn-1")) + } yield { + found.map(_.amount) shouldBe Some(-25000) + found.flatMap(_.note) shouldBe Some("Emergency withdrawal") + } + } +} diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..e652f39 --- /dev/null +++ b/build.sbt @@ -0,0 +1,102 @@ +import org.scalajs.linker.interface.ModuleKind + +ThisBuild / version := "0.1.0-SNAPSHOT" +ThisBuild / scalaVersion := "3.5.2" +ThisBuild / organization := "org.ssbudget" + +// Dependency versions (only for deps used in multiple modules) +val http4sVersion = "0.23.30" +val tapirVersion = "1.11.11" +val circeVersion = "0.14.10" +val doobieVersion = "1.0.0-RC6" +val sttpVersion = "3.10.2" + +lazy val root = (project in file(".")) + .aggregate(shared.jvm, shared.js, backend, frontend, e2e) + .settings( + name := "ssbudget", + publish := {}, + publishLocal := {} + ) + +lazy val e2e = (project in file("e2e")) + .dependsOn(backend % "test->test;test->compile") + .settings( + name := "e2e", + libraryDependencies ++= Seq( + "org.scalatest" %% "scalatest" % "3.2.19" % Test, + "org.seleniumhq.selenium" % "selenium-java" % "4.27.0" % Test, + "io.github.bonigarcia" % "webdrivermanager" % "5.9.2" % Test + ), + Test / fork := true, + Test / javaOptions ++= Seq( + s"-Duser.dir=${baseDirectory.value.getAbsolutePath}" + ) + ) + +lazy val shared = crossProject(JSPlatform, JVMPlatform) + .crossType(CrossType.Pure) + .in(file("shared")) + .settings( + name := "shared", + libraryDependencies ++= Seq( + "com.softwaremill.sttp.tapir" %%% "tapir-core" % tapirVersion, + "com.softwaremill.sttp.tapir" %%% "tapir-json-circe" % tapirVersion, + "io.circe" %%% "circe-core" % circeVersion + ) + ) + .jsSettings( + libraryDependencies ++= Seq( + "io.github.cquiroz" %%% "scala-java-time" % "2.6.0" + ) + ) + +lazy val backend = (project in file("backend")) + .enablePlugins(JavaAppPackaging) + .dependsOn(shared.jvm) + .settings( + name := "backend", + Compile / mainClass := Some("ssbudget.backend.Main"), + executableScriptName := "ssbudget", + libraryDependencies ++= Seq( + "org.typelevel" %% "cats-effect" % "3.5.7", + "org.http4s" %% "http4s-ember-server" % http4sVersion, + "org.http4s" %% "http4s-dsl" % http4sVersion, + "org.http4s" %% "http4s-circe" % http4sVersion, + "com.softwaremill.sttp.tapir" %% "tapir-http4s-server" % tapirVersion, + "com.softwaremill.sttp.tapir" %% "tapir-json-circe" % tapirVersion, + "com.softwaremill.sttp.client3" %% "cats" % sttpVersion, + "io.circe" %% "circe-generic" % circeVersion, + "ch.qos.logback" % "logback-classic" % "1.5.15", + // Database + "org.tpolecat" %% "doobie-core" % doobieVersion, + "org.tpolecat" %% "doobie-hikari" % doobieVersion, + "org.xerial" % "sqlite-jdbc" % "3.47.2.0", + "org.flywaydb" % "flyway-core" % "10.22.0", + // Authentication + "de.mkammerer" % "argon2-jvm" % "2.11", + "com.yubico" % "webauthn-server-core" % "2.5.3", + // Testing + "org.scalatest" %% "scalatest" % "3.2.19" % Test, + "org.typelevel" %% "cats-effect-testing-scalatest" % "1.6.0" % Test + ), + Compile / run / fork := true + ) + +lazy val frontend = (project in file("frontend")) + .enablePlugins(ScalaJSPlugin) + .dependsOn(shared.js) + .settings( + name := "frontend", + scalaJSLinkerConfig ~= { _.withModuleKind(ModuleKind.ESModule) }, + scalaJSUseMainModuleInitializer := true, + libraryDependencies ++= Seq( + "com.raquo" %%% "laminar" % "17.2.0", + "com.raquo" %%% "waypoint" % "10.0.0-M1", + "com.softwaremill.sttp.tapir" %%% "tapir-sttp-client" % tapirVersion, + "com.softwaremill.sttp.client3" %%% "core" % sttpVersion, + "io.circe" %%% "circe-generic" % circeVersion, + "io.circe" %%% "circe-parser" % circeVersion + ) + ) + diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..846582d --- /dev/null +++ b/build.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "=== Building backend ===" +sbt backend/stage + +echo "=== Building frontend (Scala.js) ===" +sbt frontend/fullLinkJS + +echo "=== Building frontend (Vite) ===" +cd frontend && npm install && npm run build +cd .. + +echo "=== Building Docker image ===" +docker build -t ssbudget:latest . + +echo "=== Build complete ===" +echo "Run: docker run -p 8080:8080 -v ./data:/data ssbudget:latest" diff --git a/docs/sessions/SESSION_TEMPLATE.md b/docs/sessions/SESSION_TEMPLATE.md new file mode 100644 index 0000000..a97812f --- /dev/null +++ b/docs/sessions/SESSION_TEMPLATE.md @@ -0,0 +1,48 @@ +# Session: [Session Number] - [Brief Title] + +**Date**: YYYY-MM-DD +**Phase**: [Phase from ROADMAP.md] +**Items**: [e.g., 1.1, 1.2] + +## Goal + +[One sentence describing what this session will accomplish] + +## Plan + +### Step 1: [Title] +- [ ] Sub-task +- [ ] Sub-task + +### Step 2: [Title] +- [ ] Sub-task + +## Implementation Notes + +[Notes during implementation - decisions made, problems encountered, etc.] + +## Completed + +- [x] What was done +- [x] What was done + +## Deferred / Follow-up + +- [ ] Items that were planned but not completed +- [ ] New items discovered during session + +## Files Changed + +``` +path/to/file1.scala - description +path/to/file2.scala - description +``` + +## Testing Done + +- [ ] Manual testing performed +- [ ] Automated tests added/passing + +## Next Session Recommendations + +[What should the next session focus on] diff --git a/docs/sessions/session-001.md b/docs/sessions/session-001.md new file mode 100644 index 0000000..06014b2 --- /dev/null +++ b/docs/sessions/session-001.md @@ -0,0 +1,75 @@ +# Session: 1 - Foundation & Skeleton + +**Date**: 2026-01-26 +**Phase**: 1 +**Items**: 1.1, 1.2, 1.3, 1.4 + +## Goal + +Set up multi-module sbt build with Vite integration, backend serving health endpoint, frontend displaying result. + +## Plan + +### Step 1: SBT Build Setup +- [x] Update plugins.sbt with ScalaJS plugins +- [x] Rewrite build.sbt with three modules (shared, backend, frontend) + +### Step 2: Shared Module +- [x] Create HealthEndpoint with tapir + +### Step 3: Backend +- [x] Create http4s EmberServer on port 8080 +- [x] Implement health endpoint returning "ok" + +### Step 4: Frontend +- [x] Create Laminar app +- [x] Setup Vite with vite-plugin-scalajs +- [x] Fetch and display health status using tapir client + +## Implementation Notes + +- **Scala version changed from 3.8.1 to 3.5.2**: Scala 3.8.1 has a compiler bug affecting Scala.js (`scala.scalajs.js.async` not found). Downgraded to 3.5.2 which works correctly. +- **Tapir client integration**: Frontend uses `SttpClientInterpreter` with `FetchBackend()` to call shared endpoint definitions, ensuring type safety between frontend and backend. +- **scalafmt added**: Using sbt-scalafmt with curly braces enforced (no braceless syntax). + +## Completed + +- [x] Multi-module SBT build (shared, backend, frontend) +- [x] Vite + Scala.js integration with hot reload +- [x] Backend http4s server with tapir health endpoint +- [x] Frontend Laminar app fetching health via tapir client +- [x] Bulma CSS integration +- [x] scalafmt configuration + +## Deferred / Follow-up + +- [ ] Static file serving for production (not needed for dev) +- [ ] Configuration via environment variables (not needed yet) + +## Files Changed + +``` +project/plugins.sbt - Added sbt-scalajs, sbt-scalajs-crossproject, sbt-revolver, sbt-scalafmt +build.sbt - Rewritten with multi-module setup +shared/src/main/scala/ssbudget/shared/api/HealthEndpoint.scala - Tapir endpoint definition +backend/src/main/scala/ssbudget/backend/Main.scala - http4s EmberServer +frontend/src/main/scala/ssbudget/frontend/Main.scala - Laminar app with tapir client +frontend/vite.config.mjs - Vite config with Scala.js plugin +frontend/package.json - npm dependencies +frontend/index.html - HTML entry point +.scalafmt.conf - scalafmt configuration +.gitignore - Added node_modules +CLAUDE.md - Updated Scala version, added code style rules +README.md - Development instructions +``` + +## Testing Done + +- [x] `sbt compile` - All modules compile +- [x] `sbt frontend/fastLinkJS` - Produces main.js +- [x] `curl http://localhost:8080/api/health` - Returns "ok" +- [x] Manual browser test - Frontend displays health status + +## Next Session Recommendations + +Start Phase 2: Data Layer - SQLite database setup with Flyway migrations. diff --git a/docs/sessions/session-002.md b/docs/sessions/session-002.md new file mode 100644 index 0000000..e6a99a1 --- /dev/null +++ b/docs/sessions/session-002.md @@ -0,0 +1,136 @@ +# Session 002: Data Layer Implementation + +**Date**: 2026-01-27 +**Phase**: 2 (Data Layer) +**Items Completed**: 2.1, 2.2, 2.3 + +## Summary + +Implemented the complete data layer with SQLite database, Flyway migrations, domain models, and repository layer with comprehensive test coverage. Also added utility traits for reducing JSON codec boilerplate. + +## Changes Made + +### Build Configuration +- Added doobie-core 1.0.0-RC6, doobie-hikari 1.0.0-RC6 +- Added sqlite-jdbc 3.47.2.0 +- Added flyway-core 10.22.0 +- Added scalatest 3.2.19 and cats-effect-testing-scalatest 1.6.0 for testing +- Added scala-java-time 2.6.0 to shared JS for java.time polyfill + +### Database Migration +- Created `V1__initial_schema.sql` with all tables: + - accounts (id, name, currency) + - expense_definitions (id, name, expense_type, estimate_mode, fixed_estimate, include_in_balance) + - periods (id, started_at, ended_at) - using Instant timestamps + - expense_records (id, period_id, expense_def_id, paid_amount, paid_at) - using Instant timestamps + - balance_snapshots (id, account_id, amount, currency, recorded_at) + - exchange_rates (from/to_currency, rate, fetched_at) - no id, natural key +- Money stored as INTEGER (cents), timestamps as TEXT (ISO 8601) +- Added indexes for common query patterns + +### Domain Models (shared module) +- `Money.scala` - Currency enum, Money case class with arithmetic operations +- `Account.scala` - AccountId, Account +- `ExpenseDefinition.scala` - ExpenseDefId, ExpenseType, EstimateMode, ExpenseDefinition +- `Period.scala` - PeriodId, Period (with Instant timestamps) +- `ExpenseRecord.scala` - ExpenseRecordId, ExpenseRecord (with Instant timestamps) +- `BalanceSnapshot.scala` - BalanceSnapshotId, BalanceSnapshot +- `ExchangeRate.scala` - ExchangeRate with conversion methods (no id, uses natural key) +- All models use `derives Codec.AsObject` for circe derivation + +### JSON Utilities (shared module) +- `EnumCodec.scala` - Generic utility for enum JSON codecs with custom string mappings +- `StringId.scala` - Trait for AnyVal string wrapper codecs (reduces boilerplate) + +### Database Layer (backend module) +- `Database.scala` - Transactor creation with HikariCP, Flyway migration +- `DoobieMeta.scala` - doobie Meta instances for all custom types +- `Repositories.scala` - Factory class holding all repository instances + +### Repository Implementations +- `AccountRepository.scala` - CRUD operations +- `ExpenseDefinitionRepository.scala` - CRUD + findByType +- `PeriodRepository.scala` - CRUD + findCurrent, close +- `ExpenseRecordRepository.scala` - CRUD + findByPeriod, findByPeriodAndExpense, markAsPaid +- `BalanceSnapshotRepository.scala` - CRUD + findByAccount, findLatestByAccount, findAllLatest +- `ExchangeRateRepository.scala` - create, findLatest, findAll (no findById/delete - uses natural key) + +### Tests +- Created `RepositorySpec.scala` base trait with in-memory SQLite fixture (scalatest) +- 34 tests covering all repository operations: + - AccountRepositorySpec (5 tests) + - ExpenseDefinitionRepositorySpec (6 tests) + - PeriodRepositorySpec (7 tests) + - ExpenseRecordRepositorySpec (6 tests) + - BalanceSnapshotRepositorySpec (6 tests) + - ExchangeRateRepositorySpec (4 tests) + +### Main.scala Updates +- Added database initialization on startup +- Creates data directory if it doesn't exist +- Runs Flyway migrations automatically +- Configurable via SSBUDGET_DB_PATH environment variable + +### Other +- Added `data/` to .gitignore + +## Technical Decisions + +1. **Single migration file**: All tables in V1__initial_schema.sql for clean initial setup +2. **Instant over LocalDate**: Timestamps (Instant) are more reliable for persistence than dates +3. **ExchangeRate without ID**: Uses natural key (from_currency, to_currency, fetched_at) +4. **scalatest over munit**: Better async support with cats-effect-testing-scalatest +5. **StringId trait**: Reduces boilerplate for AnyVal string wrappers +6. **EnumCodec utility**: Generic enum codec with custom string mappings +7. **Flyway via HikariCP datasource**: Required for in-memory SQLite to work correctly (shared cache mode) + +## Files Created/Modified + +### New Files +- `backend/src/main/resources/db/migration/V1__initial_schema.sql` +- `backend/src/main/scala/ssbudget/backend/db/Database.scala` +- `backend/src/main/scala/ssbudget/backend/db/DoobieMeta.scala` +- `backend/src/main/scala/ssbudget/backend/db/Repositories.scala` +- `backend/src/main/scala/ssbudget/backend/db/repository/AccountRepository.scala` +- `backend/src/main/scala/ssbudget/backend/db/repository/ExpenseDefinitionRepository.scala` +- `backend/src/main/scala/ssbudget/backend/db/repository/PeriodRepository.scala` +- `backend/src/main/scala/ssbudget/backend/db/repository/ExpenseRecordRepository.scala` +- `backend/src/main/scala/ssbudget/backend/db/repository/BalanceSnapshotRepository.scala` +- `backend/src/main/scala/ssbudget/backend/db/repository/ExchangeRateRepository.scala` +- `shared/src/main/scala/ssbudget/shared/model/Money.scala` +- `shared/src/main/scala/ssbudget/shared/model/Account.scala` +- `shared/src/main/scala/ssbudget/shared/model/ExpenseDefinition.scala` +- `shared/src/main/scala/ssbudget/shared/model/Period.scala` +- `shared/src/main/scala/ssbudget/shared/model/ExpenseRecord.scala` +- `shared/src/main/scala/ssbudget/shared/model/BalanceSnapshot.scala` +- `shared/src/main/scala/ssbudget/shared/model/ExchangeRate.scala` +- `shared/src/main/scala/ssbudget/shared/json/EnumCodec.scala` +- `shared/src/main/scala/ssbudget/shared/json/StringId.scala` +- `backend/src/test/scala/ssbudget/backend/db/repository/RepositorySpec.scala` +- `backend/src/test/scala/ssbudget/backend/db/repository/AccountRepositorySpec.scala` +- `backend/src/test/scala/ssbudget/backend/db/repository/ExpenseDefinitionRepositorySpec.scala` +- `backend/src/test/scala/ssbudget/backend/db/repository/PeriodRepositorySpec.scala` +- `backend/src/test/scala/ssbudget/backend/db/repository/ExpenseRecordRepositorySpec.scala` +- `backend/src/test/scala/ssbudget/backend/db/repository/BalanceSnapshotRepositorySpec.scala` +- `backend/src/test/scala/ssbudget/backend/db/repository/ExchangeRateRepositorySpec.scala` + +### Modified Files +- `build.sbt` - Added database and test dependencies +- `.gitignore` - Added data/ directory +- `backend/src/main/scala/ssbudget/backend/Main.scala` - Database wiring +- `ROADMAP.md` - Updated status + +## Verification + +- `sbt compile` - All modules compile successfully +- `sbt backend/test` - All 34 tests pass +- `sbt scalafmtCheckAll` - All code properly formatted +- `sbt backend/run` - Server starts, migrations run, database created at data/ssbudget.db + +## Next Steps + +Phase 3: Core Business Logic +- Period management +- Balance calculation +- Expense prediction +- Budget summary diff --git a/docs/sessions/session-003.md b/docs/sessions/session-003.md new file mode 100644 index 0000000..2b51c62 --- /dev/null +++ b/docs/sessions/session-003.md @@ -0,0 +1,130 @@ +# Session 003: Frontend Core UI Implementation + +**Date**: 2026-01-27 +**Phase**: 3 (Frontend Core UI) + 6.1-6.2 (Copy to Clipboard) +**Items Completed**: 3.1, 3.2, 3.3, 3.4, 3.5, 6.1, 6.2 + +## Summary + +Built complete frontend UI with mock data following "spreadsheet-like efficiency" design principles. Implemented all core pages (Dashboard, Budget, Accounts, Periods), unified income/expense models, added copy-to-clipboard summary feature, and created e2e test suite. + +## Changes Made + +### Build Configuration +- Added Waypoint 10.0.0-M1 for client-side routing +- Added Selenium WebDriver 4.27.0 for e2e tests +- Created new `e2e` sbt project for integration tests + +### Domain Model Changes +- Unified `ExpenseDefinition` and `IncomeDefinition` into `BudgetItemDefinition` +- Added `BudgetItemType` enum: `PlannedExpense`, `EstimatedExpense`, `PlannedIncome` +- Updated `ExpenseRecord` to work with unified budget items +- Enhanced `Money` with `formatted` method and factory methods (`pln`, `eur`) +- Deleted `IncomeDefinition.scala` and `IncomeRecord.scala` + +### Frontend Architecture +- `Page.scala` - Sealed trait page hierarchy for Waypoint routing +- `Router.scala` - Client-side routing with Waypoint +- `DataService.scala` - Trait defining reactive data signals +- `InMemoryDataService.scala` - Mock implementation with Vars +- `Formatting.scala` - Date/money formatting utilities + +### UI Components +- `Layout.scala` - App shell with navbar and page content +- `NavBar.scala` - Bootstrap navbar with active state highlighting + +### Pages +- **DashboardPage.scala** - Compact summary panel showing calculation flow (BALANCE -> AVAILABLE -> FREE / DAYS = DAILY), accounts quick view with bulk balance editing, period info with progress bar, copy summary button +- **BudgetPage.scala** - Combined planned items (expenses + incomes) with visual separator, estimated expenses with scaled calculations, inline pay/edit/delete actions +- **AccountsPage.scala** - Account list with EUR conversion display, add/edit account functionality +- **PeriodsPage.scala** - Current period with progress bar, period history, start new period button +- **NotFoundPage.scala** - 404 page + +### E2E Tests +- `E2ESpec.scala` - Base trait with common setup and helpers +- `DashboardSpec.scala` - Tests summary display, bulk balance editing, copy to clipboard +- `BudgetPageSpec.scala` - Tests paying expenses, receiving income, adding items +- `AccountsPageSpec.scala` - Tests account display and creation +- `PeriodsPageSpec.scala` - Tests period display and creation + +## Technical Decisions + +1. **Unified BudgetItemDefinition**: Incomes and expenses share the same structure, differentiated by `BudgetItemType` +2. **Bulk balance editing**: Single "Edit Balances" button makes all account balances editable at once +3. **DataService returns Money**: Cleaner UI code - signals return `Money` with `formatted` method +4. **E2ESpec base trait**: Reduces test setup duplication by ~40% +5. **Typed IDs in tests**: Uses `AccountId`, `ExpenseDefId` instead of raw strings +6. **Fixed timezone (UTC)**: Avoids `ZoneId.systemDefault()` issues in Scala.js + +## UI Design + +Following "spreadsheet-like efficiency" principles: +- Maximum information density, minimal chrome +- Direct manipulation with inline editing +- Numbers right-aligned with monospace font +- Status visible at a glance (badges, row highlighting) +- 1-2 clicks for common actions + +Dashboard summary panel shows calculation flow: +``` +BALANCE -> AVAILABLE -> FREE / DAYS = DAILY +15,000 PLN 9,500 PLN 4,000 PLN 200 PLN +``` + +## Files Created + +### Frontend +- `frontend/src/main/scala/ssbudget/frontend/Page.scala` +- `frontend/src/main/scala/ssbudget/frontend/Router.scala` +- `frontend/src/main/scala/ssbudget/frontend/components/Layout.scala` +- `frontend/src/main/scala/ssbudget/frontend/components/NavBar.scala` +- `frontend/src/main/scala/ssbudget/frontend/pages/DashboardPage.scala` +- `frontend/src/main/scala/ssbudget/frontend/pages/BudgetPage.scala` +- `frontend/src/main/scala/ssbudget/frontend/pages/AccountsPage.scala` +- `frontend/src/main/scala/ssbudget/frontend/pages/PeriodsPage.scala` +- `frontend/src/main/scala/ssbudget/frontend/pages/NotFoundPage.scala` +- `frontend/src/main/scala/ssbudget/frontend/services/DataService.scala` +- `frontend/src/main/scala/ssbudget/frontend/services/InMemoryDataService.scala` +- `frontend/src/main/scala/ssbudget/frontend/util/Formatting.scala` + +### E2E Tests +- `e2e/src/test/scala/ssbudget/e2e/E2ESpec.scala` +- `e2e/src/test/scala/ssbudget/e2e/DashboardSpec.scala` +- `e2e/src/test/scala/ssbudget/e2e/BudgetPageSpec.scala` +- `e2e/src/test/scala/ssbudget/e2e/AccountsPageSpec.scala` +- `e2e/src/test/scala/ssbudget/e2e/PeriodsPageSpec.scala` + +### Shared Model Changes +- Modified `shared/src/main/scala/ssbudget/shared/model/Money.scala` +- Created `shared/src/main/scala/ssbudget/shared/model/BudgetItemDefinition.scala` +- Deleted `shared/src/main/scala/ssbudget/shared/model/IncomeDefinition.scala` +- Deleted `shared/src/main/scala/ssbudget/shared/model/IncomeRecord.scala` + +## Files Modified +- `build.sbt` - Added Waypoint, Selenium, e2e project +- `frontend/src/main/scala/ssbudget/frontend/Main.scala` - Renders Layout +- `ROADMAP.md` - Updated status +- `CLAUDE.md` - Added Laminar/Airstream gotchas documentation + +## Verification + +- `sbt compile` - All modules compile +- `sbt '~frontend/fastLinkJS'` + `cd frontend && npm run dev` - UI works +- Navigate to all pages via navbar +- Dashboard shows calculated values, edit balances works +- Budget page: pay/receive/add items work +- Copy Summary copies to clipboard +- E2E tests pass (requires running frontend) + +## Known Issues / Gotchas + +1. **ZoneId.systemDefault()**: Fails silently in Scala.js - use fixed timezone +2. **Signal.combine with 3+ signals**: Use chained `combineWith` instead +3. **Signal.now()**: Not accessible outside Airstream - use `observe.now()` with `OneTimeOwner` + +## Next Steps + +Phase 4: API & Business Logic +- Connect frontend to real backend API +- Implement tapir endpoints for all operations +- Replace mock data service with HTTP client diff --git a/docs/sessions/session-004.md b/docs/sessions/session-004.md new file mode 100644 index 0000000..e48cc35 --- /dev/null +++ b/docs/sessions/session-004.md @@ -0,0 +1,123 @@ +# Session 004: Savings Support Implementation + +**Date**: 2026-01-27 +**Phase**: 3.5 (Savings Support) +**Items Completed**: 3.5.1, 3.5.2 + +## Summary + +Added comprehensive savings support to the budget tracker. Savings accounts are separate buckets for accumulating money (emergency fund, vacation, etc.) with editable balances, optional monthly targets, and transaction tracking. UI allows managing accounts on Accounts page and tracking transactions on Budget page. + +## Changes Made + +### Database Schema (V1__initial_schema.sql) +- Added `savings_accounts` table: id, name, currency, current_balance, planned_monthly +- Added `savings_transactions` table: id, account_id, period_id, amount, note, created_at + +### Shared Models +- `SavingsAccount.scala` - Savings account with balance and optional monthly target +- `SavingsTransaction.scala` - Transaction with amount (+/-), optional note, and timestamp +- `SavingsAccountId` and `SavingsTransactionId` - Type-safe ID wrappers + +### Backend Repositories +- `SavingsAccountRepository.scala` - CRUD + updateBalance method +- `SavingsTransactionRepository.scala` - CRUD + findByAccount, findByPeriod, findByAccountAndPeriod, deleteByAccountId +- Updated `DoobieMeta.scala` with new ID type conversions +- Updated `Repositories.scala` to include new repositories + +### Frontend DataService +Added to `DataService.scala` trait: +- `savingsAccounts: Signal[List[SavingsAccount]]` +- `savingsTransactions: Signal[List[SavingsTransaction]]` +- `currentPeriodSavingsTransactions: Signal[List[SavingsTransaction]]` +- `remainingSavingsTarget: Signal[Money]` +- CRUD methods for savings accounts and transactions + +### Frontend Pages +- **AccountsPage.scala** - Added Savings Accounts section with add/edit/delete functionality +- **BudgetPage.scala** - Added Planned Savings card with expandable rows showing transactions, supports multiple expanded rows simultaneously +- **DashboardPage.scala** - Added savings accounts to accounts table with separator, included in bulk balance editing + +### E2E Tests +Updated and added tests for savings functionality: +- `AccountsPageSpec.scala` - 6 new tests for savings account CRUD +- `BudgetPageSpec.scala` - 7 new tests for planned savings and transactions +- `DashboardSpec.scala` - Fixed clipboard test with CDP permission grant +- `E2ESpec.scala` - Fixed button text references + +## Technical Decisions + +1. **Savings vs Bank Accounts**: Kept separate from regular bank accounts - savings are "buckets" for goal tracking, not real bank accounts +2. **Transaction Tracking**: Each inflow/outflow is a transaction with optional note and timestamp +3. **Period-based Progress**: Transactions are tied to periods, progress shows contributions in current period +4. **Multi-expand Support**: Changed `expandedSavingsId: Var[Option[Id]]` to `expandedSavingsIds: Var[Set[Id]]` for better UX +5. **Remaining Target in Predictions**: `remainingSavingsTarget` included in `predictedExpenses` calculation +6. **CDP Clipboard Permissions**: Used Chrome DevTools Protocol to grant clipboard read permissions for E2E tests + +## Files Created + +### Shared Models +- `shared/src/main/scala/ssbudget/shared/model/SavingsAccount.scala` +- `shared/src/main/scala/ssbudget/shared/model/SavingsTransaction.scala` + +### Backend Repositories +- `backend/src/main/scala/ssbudget/backend/db/repository/SavingsAccountRepository.scala` +- `backend/src/main/scala/ssbudget/backend/db/repository/SavingsTransactionRepository.scala` + +### Backend Tests +- `backend/src/test/scala/ssbudget/backend/db/repository/SavingsAccountRepositorySpec.scala` +- `backend/src/test/scala/ssbudget/backend/db/repository/SavingsTransactionRepositorySpec.scala` + +## Files Modified + +### Schema +- `backend/src/main/resources/db/migration/V1__initial_schema.sql` - Added savings tables + +### Backend +- `backend/src/main/scala/ssbudget/backend/db/DoobieMeta.scala` - Added SavingsAccountId, SavingsTransactionId +- `backend/src/main/scala/ssbudget/backend/db/Repositories.scala` - Added savings repositories + +### Frontend +- `frontend/src/main/scala/ssbudget/frontend/services/DataService.scala` - Added savings signals and methods +- `frontend/src/main/scala/ssbudget/frontend/services/InMemoryDataService.scala` - Added mock implementation +- `frontend/src/main/scala/ssbudget/frontend/pages/AccountsPage.scala` - Added savings section +- `frontend/src/main/scala/ssbudget/frontend/pages/BudgetPage.scala` - Added planned savings with transactions +- `frontend/src/main/scala/ssbudget/frontend/pages/DashboardPage.scala` - Added savings to accounts table + +### E2E Tests +- `e2e/src/test/scala/ssbudget/e2e/AccountsPageSpec.scala` - Fixed + added savings tests +- `e2e/src/test/scala/ssbudget/e2e/BudgetPageSpec.scala` - Added savings transaction tests +- `e2e/src/test/scala/ssbudget/e2e/DashboardSpec.scala` - Fixed clipboard test, summary labels + +### Documentation +- `ROADMAP.md` - Added Phase 3.5, updated session log +- `CLAUDE.md` - Updated data model with savings + +## Verification + +- `sbt backend/test` - 50 tests pass (including 17 new savings tests) +- `sbt e2e/test` - 33 tests pass (13 new savings tests) +- `sbt compile` - All modules compile +- `sbt scalafmtAll` - Code formatted + +## UI Behavior + +### Accounts Page - Savings Section +- Table showing: Account name, Currency badge, Balance, Target/mo +- Add button creates new savings account with name, currency, optional target +- Edit mode allows changing name, currency, target; has delete button + +### Budget Page - Planned Savings Card +- Shows only savings accounts with targets (plannedMonthly defined) +- Columns: Account (with expand arrow), Target, Saved, Remaining +- Color coding: green when target met, yellow/warning when not +- Click row to expand/collapse (supports multiple expanded) +- Expanded view shows transactions with date, note, amount (+/-) +- "+ Add" button shows form with note input, amount (pre-filled with remaining) +- Footer shows "Remaining to Save" total + +### Dashboard - Accounts Table +- Separator row "-- Savings --" between bank and savings accounts +- Savings accounts show balance, included in bulk edit mode +- Not included in total balance (separate tracking) + diff --git a/docs/sessions/session-005.md b/docs/sessions/session-005.md new file mode 100644 index 0000000..eacad72 --- /dev/null +++ b/docs/sessions/session-005.md @@ -0,0 +1,139 @@ +# Session 005: API Integration & E2E Test Infrastructure + +**Date**: 2026-01-28 +**Phase**: 4 (API & Business Logic) +**Items Completed**: 4.1, 4.2, 4.3, 4.4, 4.5 + +## Summary + +Connected the Laminar frontend to the http4s backend, replacing in-memory mocks with real API calls and SQLite persistence. Built automated e2e test infrastructure that starts backend and frontend on random ports with fresh database. + +## Changes Made + +### API Layer + +#### Shared DTOs and Endpoints (`shared/src/main/scala/ssbudget/shared/api/`) +- `Dto.scala` - Request/response DTOs for all operations +- `Endpoints.scala` - Tapir endpoint definitions organized by domain: + - `accounts` - list, create + - `balances` - listLatest, create + - `budgetItems` - list, create, update, delete + - `expenseRecords` - listCurrent, pay, unpay + - `periods` - list, startNew + - `savingsAccounts` - list, create, update, delete + - `savingsTransactions` - listCurrent, create, delete + - `exchangeRate` - get + - `test` - reset (test mode only) + +#### Backend Routes (`backend/src/main/scala/ssbudget/backend/Routes.scala`) +- Implemented all endpoint handlers using repositories +- Business logic for startNewPeriod (creates expense records for all budget items) +- Test reset endpoint clears database and recreates schema + +#### Frontend API Client (`frontend/src/main/scala/ssbudget/frontend/services/`) +- `ApiClient.scala` - HTTP client using sttp FetchBackend, organized by domain +- `ApiDataService.scala` - Implementation of DataService that calls API and updates local Vars +- `LoadingState.scala` - Generic loading state enum (Loading, Loaded, Error) +- `Loading.scala` - UI components for loading states (spinner, actionButton) + +### E2E Test Infrastructure + +- `TestServers.scala` - Manages backend/frontend lifecycle: + - Starts backend in-process using cats-effect fibers + - Spawns Vite process for frontend + - Uses random ports and temp SQLite database + - Waits for both servers to be ready +- `E2ESuite.scala` - Master test suite that starts/stops servers +- `E2ESpec.scala` - Base trait with helper methods for test data setup +- `vite.config.e2e.mjs` - Vite config for tests with env var configuration + +### Server Refactoring +- `ServerBuilder.scala` - Extracted common server setup logic +- Main.scala and TestServers both use ServerBuilder.build() + +## Technical Decisions + +1. **Individual endpoints vs bootstrap**: Originally planned bootstrap endpoint, refactored to individual endpoints called in parallel for simpler API design + +2. **Domain organization**: Endpoints grouped by domain (accounts, balances, etc.) for easier scanning + +3. **Period end date**: Changed from 30-day hardcoded to 25th of next month + +4. **Backend returns raw data**: Frontend computes derived values (predictions, remaining savings) - reuses existing computation logic + +5. **XPath for button clicks**: Use `contains(.,'text')` instead of `contains(text(),'text')` to match text in child elements (Loading.actionButton wraps labels in spans) + +6. **Server abstraction**: Extracted ServerBuilder to avoid duplication between Main and TestServers + +## Files Created + +### Shared +- `shared/src/main/scala/ssbudget/shared/api/Dto.scala` +- `shared/src/main/scala/ssbudget/shared/api/Endpoints.scala` + +### Backend +- `backend/src/main/scala/ssbudget/backend/Routes.scala` +- `backend/src/main/scala/ssbudget/backend/ServerBuilder.scala` + +### Frontend +- `frontend/src/main/scala/ssbudget/frontend/services/ApiClient.scala` +- `frontend/src/main/scala/ssbudget/frontend/services/ApiDataService.scala` +- `frontend/src/main/scala/ssbudget/frontend/components/LoadingState.scala` +- `frontend/src/main/scala/ssbudget/frontend/components/Loading.scala` +- `frontend/vite.config.e2e.mjs` + +### E2E Tests +- `e2e/src/test/scala/ssbudget/e2e/TestServers.scala` +- `e2e/src/test/scala/ssbudget/e2e/E2ESuite.scala` + +## Files Modified + +### Backend +- `backend/src/main/scala/ssbudget/backend/Main.scala` - Uses ServerBuilder + +### Frontend +- `frontend/src/main/scala/ssbudget/frontend/services/DataService.scala` - Added initialize(), mock switching +- `frontend/src/main/scala/ssbudget/frontend/services/InMemoryDataService.scala` - Mutations return Future[Unit] +- `frontend/src/main/scala/ssbudget/frontend/Main.scala` - Calls initialize() +- `frontend/src/main/scala/ssbudget/frontend/pages/*.scala` - Use Loading.actionButton +- `frontend/src/main/scala/ssbudget/frontend/pages/PeriodsPage.scala` - Shows expected end date +- `frontend/src/main/scala/ssbudget/frontend/util/Formatting.scala` - Added formatLocalDate + +### E2E Tests +- `e2e/src/test/scala/ssbudget/e2e/E2ESpec.scala` - Added setup helpers, fixed XPath selectors +- `e2e/src/test/scala/ssbudget/e2e/*Spec.scala` - Tests create their own data + +## Verification + +- `sbt compile` - All modules compile +- `sbt backend/test` - 50 tests pass +- `sbt e2e/testOnly ssbudget.e2e.E2ESuite` - 32 tests pass +- `sbt scalafmtAll` - Code formatted + +## API Endpoints + +| Method | Path | Purpose | +|--------|------|---------| +| GET | /api/health | Health check | +| GET | /api/accounts | List accounts | +| POST | /api/accounts | Create account | +| GET | /api/balance-snapshots/latest | Latest balances per account | +| POST | /api/balance-snapshots | Create balance snapshot | +| GET | /api/budget-items | List budget items | +| POST | /api/budget-items | Create budget item | +| PUT | /api/budget-items/:id | Update budget item | +| DELETE | /api/budget-items/:id | Delete budget item | +| GET | /api/expense-records/current | Current period records | +| POST | /api/expense-records/:id/pay | Pay expense | +| POST | /api/expense-records/:id/unpay | Unpay expense | +| GET | /api/periods | List periods | +| POST | /api/periods/start | Start new period | +| GET | /api/savings-accounts | List savings accounts | +| POST | /api/savings-accounts | Create savings account | +| PUT | /api/savings-accounts/:id | Update savings account | +| DELETE | /api/savings-accounts/:id | Delete savings account | +| GET | /api/savings-transactions/current | Current period transactions | +| POST | /api/savings-transactions | Create transaction | +| DELETE | /api/savings-transactions/:id | Delete transaction | +| GET | /api/exchange-rate | Get exchange rate | +| POST | /api/test/reset | Reset database (test mode only) | diff --git a/docs/sessions/session-006.md b/docs/sessions/session-006.md new file mode 100644 index 0000000..a1ded28 --- /dev/null +++ b/docs/sessions/session-006.md @@ -0,0 +1,198 @@ +# Session 006: Authentication (Password + Passkeys) + +**Date**: 2026-01-28 +**Phase**: 5 (Authentication) +**Items Completed**: 5.1, 5.2, 5.3, 5.4, 5.5 + +## Summary + +Implemented full authentication system with both password and WebAuthn passkey support. First-time visitors set up a password, then can optionally add passkeys for passwordless login. All data endpoints are protected with session-based authentication. + +## Changes Made + +### Backend Auth Services + +#### WebAuthnService (`backend/src/main/scala/ssbudget/backend/auth/WebAuthnService.scala`) +- WebAuthn RelyingParty configuration (rpId, rpName, rpOrigins from env vars) +- Registration flow: startRegistration, finishRegistration +- Authentication flow: startAuthentication, finishAuthentication +- Thread-safe pending challenge storage using cats.effect.Ref +- Credential storage in SQLite + +#### SessionService (`backend/src/main/scala/ssbudget/backend/auth/SessionService.scala`) +- 30-day session tokens with secure random generation +- Session validation, creation, invalidation +- Expired session cleanup + +#### PasswordService (`backend/src/main/scala/ssbudget/backend/auth/PasswordService.scala`) +- Argon2id password hashing +- Password verification + +#### AuthRoutes (`backend/src/main/scala/ssbudget/backend/AuthRoutes.scala`) +- Status endpoint (check if configured, logged in, passkey count) +- Setup endpoint (initial password creation, auto-login) +- Login/logout endpoints (password-based) +- Passkey registration endpoints (authenticated) +- Passkey login endpoints (public) +- Passkey management (list, delete) +- HttpOnly session cookies with configurable secure flag + +### Database Schema + +#### V2__auth_schema.sql +- `auth_config` - singleton table for password hash +- `sessions` - session tokens with expiry +- `passkey_credentials` - WebAuthn credentials (credential_id, public_key_cose, sign_count) + +### Frontend Auth + +#### AuthState (`frontend/src/main/scala/ssbudget/frontend/auth/AuthState.scala`) +- Global auth state: Loading, NeedsSetup, NeedsLogin, LoggedIn, Error +- Initialize and refresh status from API +- Logout handling + +#### WebAuthnFacade (`frontend/src/main/scala/ssbudget/frontend/util/WebAuthnFacade.scala`) +- Browser WebAuthn API wrapper for Scala.js +- createCredential for registration +- getCredential for authentication +- Base64URL encoding/decoding + +#### Auth Pages +- `SetupPage.scala` - Initial password setup with confirmation +- `LoginPage.scala` - Password login with optional passkey button +- `SettingsPage.scala` - Passkey management (list, add, delete) + +#### Main.scala Updates +- Auth state initialization before app render +- Conditional rendering based on auth state +- Protected routes only shown when logged in + +### Shared API + +#### AuthDto.scala +- AuthStatus, SetupRequest, LoginRequest +- PasskeyInfo, PasskeyRegistrationOptions, PasskeyAuthenticationOptions +- WebAuthn response types (AttestationResponse, AssertionResponse) + +#### AuthEndpoints.scala +- Server endpoints with session cookie security +- Client endpoints without securityIn (browser sends cookies automatically) +- All auth endpoints under /api/auth/* + +### Protected Routes + +All data endpoints now require authentication: +- Routes.scala uses `serverSecurityLogic` with session validation +- Endpoints.scala defines `Secured[I, O]` type with session cookie input +- testMode flag bypasses auth for e2e tests + +### E2E Auth Tests + +#### AuthSpec.scala +- Show setup page on first visit +- Setup password and auto-login +- Logout and show login page +- Login with correct/wrong password +- Password mismatch validation on setup + +#### AuthTestServers.scala +- Separate test infrastructure with auth ENABLED (testMode=false) +- Database reset between tests + +## Technical Decisions + +1. **Password + Passkeys**: Added password auth as baseline, passkeys as upgrade path. Users must set up password first, then can add passkeys. + +2. **Session cookies**: HttpOnly cookies for security (not accessible to JS). Configurable secure flag via SSBUDGET_COOKIE_SECURE env var. + +3. **Thread-safe WebAuthn state**: Pending challenges stored in Ref[IO, Option[...]] to prevent race conditions. + +4. **testMode for existing e2e tests**: Data tests run with testMode=true to bypass auth. Auth tests run separately with testMode=false. + +5. **Client vs Server endpoints**: Separate endpoint definitions because browsers handle Set-Cookie automatically and send cookies with requests. + +## Files Created + +### Backend +- `backend/src/main/scala/ssbudget/backend/AuthRoutes.scala` +- `backend/src/main/scala/ssbudget/backend/auth/WebAuthnService.scala` +- `backend/src/main/scala/ssbudget/backend/auth/SessionService.scala` +- `backend/src/main/scala/ssbudget/backend/auth/PasswordService.scala` +- `backend/src/main/scala/ssbudget/backend/db/repository/AuthConfigRepository.scala` +- `backend/src/main/scala/ssbudget/backend/db/repository/SessionRepository.scala` +- `backend/src/main/scala/ssbudget/backend/db/repository/PasskeyCredentialRepository.scala` +- `backend/src/main/resources/db/migration/V2__auth_schema.sql` + +### Frontend +- `frontend/src/main/scala/ssbudget/frontend/auth/AuthState.scala` +- `frontend/src/main/scala/ssbudget/frontend/util/WebAuthnFacade.scala` +- `frontend/src/main/scala/ssbudget/frontend/pages/LoginPage.scala` +- `frontend/src/main/scala/ssbudget/frontend/pages/SetupPage.scala` +- `frontend/src/main/scala/ssbudget/frontend/pages/SettingsPage.scala` + +### Shared +- `shared/src/main/scala/ssbudget/shared/api/AuthDto.scala` +- `shared/src/main/scala/ssbudget/shared/api/AuthEndpoints.scala` + +### E2E +- `e2e/src/test/scala/ssbudget/e2e/AuthSpec.scala` +- `e2e/src/test/scala/ssbudget/e2e/AuthTestServers.scala` + +## Files Modified + +### Backend +- `backend/src/main/scala/ssbudget/backend/Routes.scala` - Added session validation +- `backend/src/main/scala/ssbudget/backend/ServerBuilder.scala` - WebAuthnService, auth routes +- `backend/src/main/scala/ssbudget/backend/db/Repositories.scala` - Auth repositories + +### Frontend +- `frontend/src/main/scala/ssbudget/frontend/Main.scala` - Auth state handling +- `frontend/src/main/scala/ssbudget/frontend/Page.scala` - Added Settings page +- `frontend/src/main/scala/ssbudget/frontend/Router.scala` - Settings route +- `frontend/src/main/scala/ssbudget/frontend/components/Layout.scala` - Pass apiClient +- `frontend/src/main/scala/ssbudget/frontend/components/NavBar.scala` - Logout button +- `frontend/src/main/scala/ssbudget/frontend/services/ApiClient.scala` - Auth API methods + +### Shared +- `shared/src/main/scala/ssbudget/shared/api/Endpoints.scala` - Session cookie security +- `shared/src/main/scala/ssbudget/shared/api/TapirSchemas.scala` - Auth DTO schemas + +## Code Review Fixes + +During review, fixed several issues: +1. Thread-unsafe mutable vars in WebAuthnService -> Ref[IO, ...] +2. Cookie secure flag hardcoded -> configurable via env var +3. Code duplication in AuthState -> extracted fetchAndUpdateStatus() +4. SecureRandom created per token -> shared instance +5. Inconsistent API client endpoint -> consistent use of client endpoints + +## Verification + +- `sbt compile` - All modules compile +- `sbt backend/test` - 50 tests pass +- `sbt e2e/test` - 70 tests pass (including 6 auth tests) +- `sbt scalafmtAll` - Code formatted + +## Auth Endpoints + +| Method | Path | Auth | Purpose | +|--------|------|------|---------| +| GET | /api/auth/status | Optional | Check auth state | +| POST | /api/auth/setup | No | Initial password setup | +| POST | /api/auth/login | No | Password login | +| POST | /api/auth/logout | Optional | Logout | +| POST | /api/auth/passkey/register/start | Required | Start passkey registration | +| POST | /api/auth/passkey/register/finish | Required | Complete passkey registration | +| POST | /api/auth/passkey/login/start | No | Start passkey login | +| POST | /api/auth/passkey/login/finish | No | Complete passkey login | +| GET | /api/auth/passkeys | Required | List registered passkeys | +| DELETE | /api/auth/passkeys/:id | Required | Delete passkey | + +## Environment Variables + +| Variable | Default | Purpose | +|----------|---------|---------| +| SSBUDGET_RP_ID | localhost | WebAuthn Relying Party ID | +| SSBUDGET_RP_NAME | SSBudget | WebAuthn Relying Party name | +| SSBUDGET_RP_ORIGINS | http://localhost:3000,http://localhost:8080 | Allowed origins | +| SSBUDGET_COOKIE_SECURE | false | Set to true for HTTPS | diff --git a/docs/sessions/session-007.md b/docs/sessions/session-007.md new file mode 100644 index 0000000..7d677a3 --- /dev/null +++ b/docs/sessions/session-007.md @@ -0,0 +1,158 @@ +# Session 007: Multi-Currency Support + +**Date**: 2026-01-29 +**Phase**: 8 (Polish & Extras) +**Items Completed**: 8.1 (Exchange Rate API), Multi-currency configuration + +## Summary + +Redesigned currency handling from a hardcoded PLN/EUR enum to a configurable system. Users can now enable currencies from a list of 32 fiat currencies, set a primary currency for totals and calculations, and fetch exchange rates from an external API (Frankfurter). Includes a searchable dropdown for adding new currencies. + +## Key Changes + +### Data Model Changes + +#### Currency type redesign +- **Changed from enum to value class**: `final case class Currency(code: String) extends AnyVal` +- **Added known currencies list**: 32 ISO 4217 codes with names (AUD, BGN, BRL, CAD, CHF, CNY, CZK, DKK, EUR, GBP, HKD, HUF, IDR, ILS, INR, ISK, JPY, KRW, MXN, MYR, NOK, NZD, PHP, PLN, RON, SEK, SGD, THB, TRY, USD, ZAR) +- **File**: `shared/src/main/scala/ssbudget/shared/model/Money.scala` + +#### New model: CurrencySetting +```scala +final case class CurrencySetting( + code: Currency, + name: String, + isPrimary: Boolean, + enabledAt: Instant, +) +``` +- **File**: `shared/src/main/scala/ssbudget/shared/model/CurrencySetting.scala` + +### Database Migration + +**File**: `backend/src/main/resources/db/migration/V3__currency_settings.sql` + +- Created `currency_settings` table with code (PK), name, is_primary, enabled_at +- Unique partial index ensures exactly one primary currency +- Recreated accounts, balance_snapshots, exchange_rates, savings_accounts tables without CHECK constraints (replaced with FK references) +- Seeded PLN (primary) and EUR +- Used ISO 8601 timestamp format for Java Instant parsing compatibility + +### Backend Implementation + +#### CurrencySettingsRepository +- **File**: `backend/src/main/scala/ssbudget/backend/db/repository/CurrencySettingsRepository.scala` +- CRUD operations: findAll, findByCode, findPrimary, create, setPrimary, delete + +#### CurrencyService +- **File**: `backend/src/main/scala/ssbudget/backend/service/CurrencyService.scala` +- `getSettings()` - Returns enabled currencies and available currencies list +- `enableCurrency(code)` - Validates against known currencies, creates setting +- `disableCurrency(code)` - Validates not primary, not in use by accounts +- `setPrimaryCurrency(code)` - Updates primary flag atomically +- `refreshRates()` - Fetches from Frankfurter API (https://api.frankfurter.dev) + +#### New Endpoints +- `GET /api/currencies` - Get settings and available currencies +- `POST /api/currencies` - Enable a currency +- `DELETE /api/currencies/{code}` - Disable a currency +- `PUT /api/currencies/primary` - Set primary currency +- `POST /api/currencies/refresh` - Refresh exchange rates from API + +### Frontend Implementation + +#### DataService updates +- Added `currencySettings: Signal[List[CurrencySetting]]` +- Added `availableCurrencies: Signal[List[(String, String)]]` +- Added `enabledCurrencies: Signal[List[Currency]]` +- Added `primaryCurrency: Signal[Currency]` +- Methods: `enableCurrency()`, `disableCurrency()`, `setPrimaryCurrency()`, `refreshExchangeRates()` + +#### SettingsPage Currencies Card +- Table showing enabled currencies with exchange rates +- Primary currency marked, cannot be removed +- "Set Primary" button on non-primary currencies +- Remove button (disabled for primary) +- **Searchable datalist dropdown** for adding currencies +- Shows available currency count +- "Refresh Rates" button in header + +#### AccountsPage updates +- Currency selects now use `dataService.enabledCurrencies` instead of hardcoded enum +- Dynamic options based on configured currencies + +### E2E Tests + +**File**: `e2e/src/test/scala/ssbudget/e2e/CurrencySettingsSpec.scala` + +8 new tests: +1. Settings page shows Currencies card with PLN and EUR +2. Shows PLN as primary currency by default +3. Has Refresh Rates button +4. Can add a new currency (USD) +5. Can set a different currency as primary +6. Can remove a non-primary currency +7. Does not show remove button for primary currency +8. Shows enabled currencies in account creation dropdown + +## Files Created + +- `shared/src/main/scala/ssbudget/shared/model/CurrencySetting.scala` +- `backend/src/main/resources/db/migration/V3__currency_settings.sql` +- `backend/src/main/scala/ssbudget/backend/db/repository/CurrencySettingsRepository.scala` +- `backend/src/main/scala/ssbudget/backend/service/CurrencyService.scala` +- `e2e/src/test/scala/ssbudget/e2e/CurrencySettingsSpec.scala` + +## Files Modified + +### Shared +- `shared/src/main/scala/ssbudget/shared/model/Money.scala` - Currency type change, knownCurrencies list +- `shared/src/main/scala/ssbudget/shared/api/Dto.scala` - New DTOs (EnableCurrencyRequest, SetPrimaryCurrencyRequest, KnownCurrency, CurrencySettingsResponse, ExchangeRatesResponse) +- `shared/src/main/scala/ssbudget/shared/api/Endpoints.scala` - Currency endpoints (server + client) +- `shared/src/main/scala/ssbudget/shared/api/TapirSchemas.scala` - New schemas + +### Backend +- `build.sbt` - Added http4s-ember-client dependency +- `backend/src/main/scala/ssbudget/backend/db/DoobieMeta.scala` - Currency meta for value class +- `backend/src/main/scala/ssbudget/backend/db/Repositories.scala` - CurrencySettingsRepository +- `backend/src/main/scala/ssbudget/backend/Routes.scala` - Currency endpoints handlers +- `backend/src/main/scala/ssbudget/backend/ServerBuilder.scala` - HTTP client, CurrencyService wiring + +### Frontend +- `frontend/src/main/scala/ssbudget/frontend/services/DataService.scala` - Currency signals and methods +- `frontend/src/main/scala/ssbudget/frontend/services/ApiDataService.scala` - API implementation +- `frontend/src/main/scala/ssbudget/frontend/services/ApiClient.scala` - Currency API methods +- `frontend/src/main/scala/ssbudget/frontend/services/InMemoryDataService.scala` - Mock implementation +- `frontend/src/main/scala/ssbudget/frontend/pages/SettingsPage.scala` - Currencies card with datalist +- `frontend/src/main/scala/ssbudget/frontend/pages/AccountsPage.scala` - Dynamic currency selects + +### E2E +- `e2e/src/test/scala/ssbudget/e2e/E2ESpec.scala` - Added findCardByH5 helper +- `e2e/src/test/scala/ssbudget/e2e/E2ESuite.scala` - Added CurrencySettingsSpec + +## Technical Decisions + +1. **Value class for Currency**: Allows database-driven configuration without enum recompilation +2. **Frankfurter API**: Free, no API key, uses ECB data, 32 currencies +3. **Partial unique index for primary**: SQLite feature to ensure exactly one primary +4. **ISO 8601 timestamps in migration**: Required for Java Instant parsing (`strftime('%Y-%m-%dT%H:%M:%SZ', 'now')`) +5. **Datalist for currency selection**: HTML5 feature for searchable dropdown with autocomplete +6. **Available currencies filtered**: Already-enabled currencies hidden from add dropdown + +## Verification + +- `sbt backend/compile` - Success +- `sbt frontend/compile` - Success +- `sbt backend/test` - 50 tests pass +- `sbt e2e/test` - 86 tests pass (78 existing + 8 new) +- `sbt scalafmtAll` - Code formatted + +## API Reference + +| Method | Path | Auth | Purpose | +|--------|------|------|---------| +| GET | /api/currencies | Required | Get enabled currencies and available list | +| POST | /api/currencies | Required | Enable a currency | +| DELETE | /api/currencies/{code} | Required | Disable a currency | +| PUT | /api/currencies/primary | Required | Set primary currency | +| POST | /api/currencies/refresh | Required | Refresh rates from Frankfurter API | diff --git a/docs/sessions/session-008.md b/docs/sessions/session-008.md new file mode 100644 index 0000000..0f7e688 --- /dev/null +++ b/docs/sessions/session-008.md @@ -0,0 +1,75 @@ +# Session: 008 - Database Backup/Restore + +**Date**: 2026-01-29 +**Phase**: 8 (Polish & Extras) +**Items**: 8.4 + +## Goal + +Add database import and export functionality for backup and restore purposes. + +## Plan + +### Step 1: Backend Export/Import Endpoints +- [x] Add tapir endpoints for database download and import +- [x] Implement export as file download +- [x] Implement import with SQLite backup API for live restore + +### Step 2: Frontend UI +- [x] Add Data card to Settings page +- [x] Export button (download link) +- [x] Import button with file picker + +### Step 3: E2E Tests +- [x] Create DatabaseSpec with functional tests for export/import + +## Implementation Notes + +**Initial approach (file replacement)**: First attempted to replace the SQLite file directly during import. This caused `SQLITE_READONLY_DBMOVED` error because HikariCP's connection pool held references to the old file. + +**Final approach (SQLite backup API)**: Used `org.sqlite.SQLiteConnection.getDatabase.restore()` to copy data from uploaded file into the running database. This works without restart because it operates through an existing connection. + +**Tapir consistency**: Initially implemented with raw http4s routes, then refactored to use tapir endpoints for consistency with the rest of the codebase. Added `Endpoints.database.download` and `Endpoints.database.import` in shared module. + +**Scala 3 keyword**: `export` is a reserved keyword in Scala 3, renamed endpoint to `download`. + +## Completed + +- [x] Backend export endpoint (GET /api/database/export) - returns SQLite file with timestamped filename +- [x] Backend import endpoint (POST /api/database/import) - accepts raw bytes, validates SQLite header, restores via backup API +- [x] Frontend Data card on Settings page with Export/Import buttons +- [x] Warning message about data replacement +- [x] 3 functional e2e tests (export downloads valid SQLite, import restores data, invalid file shows error) + +## Deferred / Follow-up + +- [ ] CSV export (lower priority, SQLite export covers backup use case) + +## Files Changed + +``` +shared/src/main/scala/ssbudget/shared/api/Endpoints.scala - Added database.download and database.import endpoints +backend/src/main/scala/ssbudget/backend/Routes.scala - Added exportDatabase and importDatabase handlers +backend/src/main/scala/ssbudget/backend/ServerBuilder.scala - Pass transactor to Routes +backend/src/main/scala/ssbudget/backend/Main.scala - Pass transactor to ServerBuilder +frontend/src/main/scala/ssbudget/frontend/pages/SettingsPage.scala - Added Data card with export/import UI +e2e/src/test/scala/ssbudget/e2e/DatabaseSpec.scala - New test spec (5 tests) +e2e/src/test/scala/ssbudget/e2e/E2ESuite.scala - Added DatabaseSpec to suite +e2e/src/test/scala/ssbudget/e2e/TestServers.scala - Updated for new ServerBuilder signature +e2e/src/test/scala/ssbudget/e2e/AuthTestServers.scala - Updated for new ServerBuilder signature +``` + +## Testing Done + +- [x] Manual testing of export (downloads .db file) +- [x] Manual testing of import (restores database without restart) +- [x] 3 functional e2e tests passing (DatabaseSpec): + - Export downloads valid SQLite file (verified header) + - Import restores data from backup (creates data, exports, adds more data, imports, verifies original data restored) + - Import shows error for invalid file +- [x] All existing e2e tests still passing + +## Next Session Recommendations + +- Phase 9 (Production Hardening): Docker deployment, error handling, logging +- Or Phase 8.2/8.3: Historical data views, mobile optimization diff --git a/e2e/src/test/scala/ssbudget/e2e/AccountsPageSpec.scala b/e2e/src/test/scala/ssbudget/e2e/AccountsPageSpec.scala new file mode 100644 index 0000000..85aa0e6 --- /dev/null +++ b/e2e/src/test/scala/ssbudget/e2e/AccountsPageSpec.scala @@ -0,0 +1,156 @@ +package ssbudget.e2e + +import org.openqa.selenium.By +import scala.jdk.CollectionConverters.* + +class AccountsPageSpec extends E2ESpec { + + // ============ Bank Accounts ============ + + "Accounts page" should "load and show bank accounts card" in { + driver.get(s"$baseUrl/accounts") + waitForPage("Accounts") + + val cardTexts = driver.findElements(By.cssSelector(".card")).asScala.map(_.getText).toList + cardTexts.exists(_.contains("Bank Accounts")) shouldBe true + } + + it should "add a new bank account" in { + driver.get(s"$baseUrl/accounts") + waitForPage("Accounts") + + val bankCard = findCard("Bank Accounts") + val initialCount = rows(bankCard).size + click(bankCard, "+ Add") + + val addRow = bankCard.findElement(By.cssSelector("tbody tr.table-primary")) + addRow.findElement(By.cssSelector("input[type='text']")).sendKeys("Test Account") + click(addRow, "Add") + + rows(bankCard).size shouldBe (initialCount + 1) + } + + it should "cancel adding bank account" in { + driver.get(s"$baseUrl/accounts") + waitForPage("Accounts") + + val bankCard = findCard("Bank Accounts") + val initialCount = rows(bankCard).size + click(bankCard, "+ Add") + click(bankCard.findElement(By.cssSelector("tbody tr.table-primary")), "Cancel") + + rows(bankCard).size shouldBe initialCount + } + + it should "enter and cancel edit mode for bank account" in { + addBankAccount("Edit Test Account") + + driver.get(s"$baseUrl/accounts") + waitForPage("Accounts") + + val bankCard = findCard("Bank Accounts") + val firstRow = bankCard.findElement(By.cssSelector("tbody tr")) + val initialName = firstRow.findElement(By.cssSelector("td:first-child")).getText + + click(firstRow, "Edit") + bankCard.findElement(By.cssSelector("tbody tr.table-warning")).isDisplayed shouldBe true + + click(bankCard.findElement(By.cssSelector("tbody tr.table-warning")), "Cancel") + bankCard.findElement(By.cssSelector("tbody tr td:first-child")).getText shouldBe initialName + } + + it should "show total balance in footer" in { + addBankAccount("Footer Test Account") + + driver.get(s"$baseUrl/accounts") + waitForPage("Accounts") + + val bankCard = findCard("Bank Accounts") + val footerText = bankCard.findElement(By.cssSelector(".card-footer")).getText + footerText should include("Total:") + } + + // ============ Savings Accounts ============ + + it should "show savings accounts section" in { + driver.get(s"$baseUrl/accounts") + waitForPage("Accounts") + + val savingsCard = findCard("Savings Accounts") + savingsCard.isDisplayed shouldBe true + } + + it should "add a new savings account" in { + driver.get(s"$baseUrl/accounts") + waitForPage("Accounts") + + val savingsCard = findCard("Savings Accounts") + val initialCount = rows(savingsCard).size + click(savingsCard, "+ Add") + + val addRow = savingsCard.findElement(By.cssSelector("tbody tr.table-success")) + addRow.findElement(By.cssSelector("input[type='text']")).sendKeys("New Savings") + addRow.findElement(By.cssSelector("input[type='number']")).sendKeys("200") + click(addRow, "Add") + + rows(savingsCard).size shouldBe (initialCount + 1) + rows(savingsCard).exists(_.getText.contains("New Savings")) shouldBe true + } + + it should "cancel adding savings account" in { + driver.get(s"$baseUrl/accounts") + waitForPage("Accounts") + + val savingsCard = findCard("Savings Accounts") + val initialCount = rows(savingsCard).size + click(savingsCard, "+ Add") + click(savingsCard.findElement(By.cssSelector("tbody tr.table-success")), "Cancel") + + rows(savingsCard).size shouldBe initialCount + } + + it should "edit savings account" in { + addSavingsAccount("Edit Savings Test", Some(500)) + + driver.get(s"$baseUrl/accounts") + waitForPage("Accounts") + + val savingsCard = findCard("Savings Accounts") + val targetRow = savingsCard.findElement(By.xpath(".//tr[.//td[contains(text(),'Edit Savings Test')]]")) + click(targetRow, "Edit") + + val editRow = savingsCard.findElement(By.cssSelector("tbody tr.table-warning")) + editRow.isDisplayed shouldBe true + + val targetInput = editRow.findElement(By.cssSelector("input[type='number']")) + targetInput.clear() + targetInput.sendKeys("999") + click(editRow, "Save") + + savingsCard.getText should include("999") + } + + it should "delete savings account" in { + driver.get(s"$baseUrl/accounts") + waitForPage("Accounts") + + // First add an account to delete + val savingsCard = findCard("Savings Accounts") + click(savingsCard, "+ Add") + + val addRow = savingsCard.findElement(By.cssSelector("tbody tr.table-success")) + addRow.findElement(By.cssSelector("input[type='text']")).sendKeys("To Delete") + click(addRow, "Add") + + val countAfterAdd = rows(savingsCard).size + rows(savingsCard).exists(_.getText.contains("To Delete")) shouldBe true + + // Now delete it + val toDelete = savingsCard.findElement(By.xpath(".//tr[.//td[contains(text(),'To Delete')]]")) + click(toDelete, "Edit") + click(savingsCard.findElement(By.cssSelector("tbody tr.table-warning")), "Del") + + rows(savingsCard).size shouldBe (countAfterAdd - 1) + rows(savingsCard).exists(_.getText.contains("To Delete")) shouldBe false + } +} diff --git a/e2e/src/test/scala/ssbudget/e2e/AuthSpec.scala b/e2e/src/test/scala/ssbudget/e2e/AuthSpec.scala new file mode 100644 index 0000000..4f17f8e --- /dev/null +++ b/e2e/src/test/scala/ssbudget/e2e/AuthSpec.scala @@ -0,0 +1,159 @@ +package ssbudget.e2e + +import io.github.bonigarcia.wdm.WebDriverManager +import org.openqa.selenium.{By, WebDriver} +import org.openqa.selenium.chrome.{ChromeDriver, ChromeOptions} +import org.openqa.selenium.support.ui.{ExpectedConditions, WebDriverWait} +import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach} +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import java.time.Duration + +/** Auth e2e tests - run separately from main suite since they need auth enabled. Run with: sbt "e2e/testOnly ssbudget.e2e.AuthSpec" + */ +class AuthSpec extends AnyFlatSpec with Matchers with BeforeAndAfterAll with BeforeAndAfterEach { + + import scala.compiletime.uninitialized + protected var driver: WebDriver = uninitialized + + protected def baseUrl: String = AuthTestServers.frontendUrl + + override def beforeAll(): Unit = { + AuthTestServers.startAll() + WebDriverManager.chromedriver().setup() + } + + override def afterAll(): Unit = { + AuthTestServers.stopAll() + } + + override def beforeEach(): Unit = { + val options = new ChromeOptions() + options.addArguments("--headless", "--no-sandbox", "--disable-dev-shm-usage", "--window-size=1920,1080") + driver = new ChromeDriver(options) + driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(10)) + } + + override def afterEach(): Unit = { + if driver != null then driver.quit() + // Reset database between tests + AuthTestServers.resetDatabase() + } + + protected def waitFor: WebDriverWait = new WebDriverWait(driver, Duration.ofSeconds(10)) + + behavior of "Authentication" + + it should "show setup page on first visit" in { + driver.get(baseUrl) + + // Wait for setup page to appear + waitFor.until(ExpectedConditions.textToBePresentInElementLocated(By.tagName("h3"), "SSBudget Setup")) + + // Should see password setup form + driver.findElement(By.id("password")).isDisplayed shouldBe true + driver.findElement(By.id("confirm")).isDisplayed shouldBe true + driver.findElement(By.xpath("//button[text()='Create Password']")).isDisplayed shouldBe true + } + + it should "setup password and auto-login" in { + driver.get(baseUrl) + + // Wait for setup page + waitFor.until(ExpectedConditions.textToBePresentInElementLocated(By.tagName("h3"), "SSBudget Setup")) + + // Enter password + driver.findElement(By.id("password")).sendKeys("testpassword123") + driver.findElement(By.id("confirm")).sendKeys("testpassword123") + + // Click create + driver.findElement(By.xpath("//button[text()='Create Password']")).click() + + // Should redirect to dashboard after auto-login + waitFor.until(ExpectedConditions.textToBePresentInElementLocated(By.tagName("h4"), "Dashboard")) + Thread.sleep(500) + + // Should see navbar with logout button + waitFor.until(ExpectedConditions.visibilityOfElementLocated(By.xpath("//button[text()='Logout']"))) + } + + it should "logout and show login page" in { + // First setup and login + driver.get(baseUrl) + waitFor.until(ExpectedConditions.textToBePresentInElementLocated(By.tagName("h3"), "SSBudget Setup")) + driver.findElement(By.id("password")).sendKeys("testpassword123") + driver.findElement(By.id("confirm")).sendKeys("testpassword123") + driver.findElement(By.xpath("//button[text()='Create Password']")).click() + waitFor.until(ExpectedConditions.textToBePresentInElementLocated(By.tagName("h4"), "Dashboard")) + Thread.sleep(500) + + // Click logout (wait for it to be visible first) + waitFor.until(ExpectedConditions.elementToBeClickable(By.xpath("//button[text()='Logout']"))).click() + + // Should show login page + waitFor.until(ExpectedConditions.textToBePresentInElementLocated(By.tagName("h3"), "SSBudget")) + waitFor.until(ExpectedConditions.visibilityOfElementLocated(By.id("password"))) + waitFor.until(ExpectedConditions.visibilityOfElementLocated(By.xpath("//button[text()='Sign In']"))) + } + + it should "login with correct password" in { + // First setup + driver.get(baseUrl) + waitFor.until(ExpectedConditions.textToBePresentInElementLocated(By.tagName("h3"), "SSBudget Setup")) + driver.findElement(By.id("password")).sendKeys("testpassword123") + driver.findElement(By.id("confirm")).sendKeys("testpassword123") + driver.findElement(By.xpath("//button[text()='Create Password']")).click() + waitFor.until(ExpectedConditions.textToBePresentInElementLocated(By.tagName("h4"), "Dashboard")) + Thread.sleep(500) + + // Logout + waitFor.until(ExpectedConditions.elementToBeClickable(By.xpath("//button[text()='Logout']"))).click() + waitFor.until(ExpectedConditions.textToBePresentInElementLocated(By.tagName("h3"), "SSBudget")) + + // Login + waitFor.until(ExpectedConditions.visibilityOfElementLocated(By.id("password"))).sendKeys("testpassword123") + driver.findElement(By.xpath("//button[text()='Sign In']")).click() + + // Should show dashboard + waitFor.until(ExpectedConditions.textToBePresentInElementLocated(By.tagName("h4"), "Dashboard")) + } + + it should "show error for wrong password" in { + // First setup + driver.get(baseUrl) + waitFor.until(ExpectedConditions.textToBePresentInElementLocated(By.tagName("h3"), "SSBudget Setup")) + driver.findElement(By.id("password")).sendKeys("testpassword123") + driver.findElement(By.id("confirm")).sendKeys("testpassword123") + driver.findElement(By.xpath("//button[text()='Create Password']")).click() + waitFor.until(ExpectedConditions.textToBePresentInElementLocated(By.tagName("h4"), "Dashboard")) + Thread.sleep(500) + + // Logout + driver.findElement(By.xpath("//button[text()='Logout']")).click() + waitFor.until(ExpectedConditions.textToBePresentInElementLocated(By.tagName("h3"), "SSBudget")) + + // Try wrong password + driver.findElement(By.id("password")).sendKeys("wrongpassword") + driver.findElement(By.xpath("//button[text()='Sign In']")).click() + + // Should show error + waitFor.until(ExpectedConditions.textToBePresentInElementLocated(By.className("alert-danger"), "Invalid password")) + + // Should still be on login page + driver.findElement(By.id("password")).isDisplayed shouldBe true + } + + it should "show password mismatch error on setup" in { + driver.get(baseUrl) + waitFor.until(ExpectedConditions.textToBePresentInElementLocated(By.tagName("h3"), "SSBudget Setup")) + + // Enter mismatched passwords + driver.findElement(By.id("password")).sendKeys("password1") + driver.findElement(By.id("confirm")).sendKeys("password2") + + // Button should be disabled when passwords don't match + val button = driver.findElement(By.xpath("//button[text()='Create Password']")) + button.isEnabled shouldBe false + } +} diff --git a/e2e/src/test/scala/ssbudget/e2e/AuthTestServers.scala b/e2e/src/test/scala/ssbudget/e2e/AuthTestServers.scala new file mode 100644 index 0000000..c4afc85 --- /dev/null +++ b/e2e/src/test/scala/ssbudget/e2e/AuthTestServers.scala @@ -0,0 +1,209 @@ +package ssbudget.e2e + +import cats.effect.IO +import cats.effect.unsafe.implicits.global +import com.comcast.ip4s.Port +import ssbudget.backend.ServerBuilder +import ssbudget.backend.db.{Database, Repositories} + +import java.io.File +import java.net.{HttpURLConnection, ServerSocket, URL} +import java.nio.file.{Files, Path} +import scala.sys.process.{Process, ProcessLogger} +import scala.util.{Try, Using} + +/** Test servers for auth tests - runs WITHOUT testMode so authentication is enforced. */ +object AuthTestServers { + + @volatile private var backendFiber: Option[cats.effect.FiberIO[Nothing]] = None + @volatile private var frontendProcess: Option[scala.sys.process.Process] = None + @volatile private var _backendPort: Int = 0 + @volatile private var _frontendPort: Int = 0 + @volatile private var dbPath: Option[Path] = None + @volatile private var jdbcUrl: String = "" + + def backendPort: Int = _backendPort + def frontendPort: Int = _frontendPort + // Use localhost instead of 127.0.0.1 for WebAuthn secure context compatibility + def frontendUrl: String = s"http://localhost:$_frontendPort" + + private def findAvailablePort(): Int = { + Using(new ServerSocket(0)) { socket => + socket.setReuseAddress(true) + socket.getLocalPort + }.get + } + + def startAll(): Unit = { + if backendFiber.isDefined then { + println("[AuthE2E] Servers already running") + return + } + + _backendPort = findAvailablePort() + _frontendPort = findAvailablePort() + + println(s"[AuthE2E] Starting backend on port $_backendPort (auth ENABLED)") + println(s"[AuthE2E] Starting frontend on port $_frontendPort") + + startBackend() + startFrontend() + + waitForServer(s"http://127.0.0.1:$_backendPort/api/health", "Backend") + waitForServer(s"http://127.0.0.1:$_frontendPort", "Frontend") + + println("[AuthE2E] All servers ready") + } + + private def startBackend(): Unit = { + val tempDb = Files.createTempFile("ssbudget-auth-e2e-", ".db") + dbPath = Some(tempDb) + jdbcUrl = s"jdbc:sqlite:${tempDb.toAbsolutePath}" + val port = Port.fromInt(_backendPort).get + val dbPathStr = tempDb.toAbsolutePath.toString + // Configure WebAuthn origins to include the dynamic frontend port + val webAuthnOrigins = Some(Set(frontendUrl)) + + // NOTE: testMode = false - authentication is ENABLED + val serverIO: IO[Nothing] = Database.migrateAndTransactor(jdbcUrl).use { xa => + val repos = Repositories.fromTransactor(xa) + ServerBuilder.build(repos, xa, port, testMode = false, dbPath = dbPathStr, webAuthnOrigins = webAuthnOrigins).useForever + } + + backendFiber = Some(serverIO.start.unsafeRunSync()) + } + + private def startFrontend(): Unit = { + val e2eDir = new File(System.getProperty("user.dir")) + val projectRoot = e2eDir.getParentFile + val frontendDir = new File(projectRoot, "frontend") + + println(s"[AuthE2E] Frontend dir = ${frontendDir.getAbsolutePath}") + + if !frontendDir.exists() then { + throw new RuntimeException(s"Frontend directory not found: ${frontendDir.getAbsolutePath}") + } + + val viteConfig = new File(frontendDir, "vite.config.e2e.mjs") + if !viteConfig.exists() then { + throw new RuntimeException(s"Vite config not found: ${viteConfig.getAbsolutePath}") + } + + val env = Seq( + "VITE_PORT" -> _frontendPort.toString, + "VITE_API_URL" -> s"http://localhost:$_backendPort", + ) + + val cmd = Seq("npx", "vite", "--config", "vite.config.e2e.mjs") + println(s"[AuthE2E] Running: ${cmd.mkString(" ")} in ${frontendDir.getAbsolutePath}") + + val pb = Process(cmd, frontendDir, env*) + + val logger = ProcessLogger( + out => println(s"[vite] $out"), + err => println(s"[vite-err] $err"), + ) + + frontendProcess = Some(pb.run(logger)) + } + + private def waitForServer(url: String, name: String, maxAttempts: Int = 60): Unit = { + var attempts = 0 + var ready = false + + while !ready && attempts < maxAttempts do { + val result = Try { + val connection = new URL(url).openConnection().asInstanceOf[HttpURLConnection] + connection.setConnectTimeout(2000) + connection.setReadTimeout(2000) + connection.setRequestMethod("GET") + try { + connection.connect() + val code = connection.getResponseCode + (code, code >= 200 && code < 500) + } finally { + connection.disconnect() + } + } + + result match { + case scala.util.Success((code, isReady)) => + if isReady then { + ready = true + } else { + if attempts % 10 == 0 then println(s"[AuthE2E] $name returned $code, retrying...") + attempts += 1 + Thread.sleep(1000) + } + case scala.util.Failure(ex) => + if attempts % 10 == 0 then println(s"[AuthE2E] $name connection failed: ${ex.getMessage}, retrying...") + attempts += 1 + Thread.sleep(1000) + } + } + + if !ready then { + throw new RuntimeException(s"$name failed to start at $url after $maxAttempts seconds") + } + + println(s"[AuthE2E] $name is ready at $url") + } + + /** Reset the database by deleting and recreating it. This restarts the backend. */ + def resetDatabase(): Unit = { + // Stop backend + backendFiber.foreach { fiber => + fiber.cancel.unsafeRunSync() + } + backendFiber = None + + // Delete old database + dbPath.foreach { path => + Try(Files.deleteIfExists(path)) + } + + // Create new database and restart backend + val tempDb = Files.createTempFile("ssbudget-auth-e2e-", ".db") + dbPath = Some(tempDb) + jdbcUrl = s"jdbc:sqlite:${tempDb.toAbsolutePath}" + val port = Port.fromInt(_backendPort).get + val dbPathStr = tempDb.toAbsolutePath.toString + // Configure WebAuthn origins to include the dynamic frontend port + val webAuthnOrigins = Some(Set(frontendUrl)) + + val serverIO: IO[Nothing] = Database.migrateAndTransactor(jdbcUrl).use { xa => + val repos = Repositories.fromTransactor(xa) + ServerBuilder.build(repos, xa, port, testMode = false, dbPath = dbPathStr, webAuthnOrigins = webAuthnOrigins).useForever + } + + backendFiber = Some(serverIO.start.unsafeRunSync()) + + // Wait for backend to be ready again + waitForServer(s"http://127.0.0.1:$_backendPort/api/health", "Backend (reset)") + } + + def stopAll(): Unit = { + println("[AuthE2E] Stopping servers...") + + frontendProcess.foreach { p => + p.destroy() + Thread.sleep(200) + } + frontendProcess = None + + backendFiber.foreach { fiber => + fiber.cancel.unsafeRunSync() + } + backendFiber = None + + dbPath.foreach { path => + Try(Files.deleteIfExists(path)) + } + dbPath = None + + _backendPort = 0 + _frontendPort = 0 + + println("[AuthE2E] Servers stopped") + } +} diff --git a/e2e/src/test/scala/ssbudget/e2e/BudgetPageSpec.scala b/e2e/src/test/scala/ssbudget/e2e/BudgetPageSpec.scala new file mode 100644 index 0000000..b82495a --- /dev/null +++ b/e2e/src/test/scala/ssbudget/e2e/BudgetPageSpec.scala @@ -0,0 +1,246 @@ +package ssbudget.e2e + +import org.openqa.selenium.By +import scala.jdk.CollectionConverters.* + +class BudgetPageSpec extends E2ESpec { + + "Budget page" should "load planned items and estimated expenses cards" in { + ensurePeriodExists() + + driver.get(s"$baseUrl/budget") + waitForPage("Budget") + + val cardTexts = driver.findElements(By.cssSelector(".card")).asScala.map(_.getText).toList + cardTexts.exists(_.contains("Planned Items")) shouldBe true + cardTexts.exists(_.contains("Estimated Expenses")) shouldBe true + } + + it should "add a new planned expense" in { + ensurePeriodExists() + + driver.get(s"$baseUrl/budget") + waitForPage("Budget") + + val card = findCard("Planned Items") + click(card, "+ Expense") + + val addRow = card.findElement(By.cssSelector("tr.table-primary")) + addRow.findElement(By.cssSelector("input[type='text']")).sendKeys("Test Expense") + addRow.findElement(By.cssSelector("input[type='number']")).sendKeys("123.45") + click(addRow, "Add") + + rows(card).exists(_.getText.contains("Test Expense")) shouldBe true + } + + it should "add a new planned income" in { + ensurePeriodExists() + + driver.get(s"$baseUrl/budget") + waitForPage("Budget") + + val card = findCard("Planned Items") + click(card, "+ Income") + + val addRow = card.findElement(By.cssSelector("tr.table-primary")) + addRow.findElement(By.cssSelector("input[type='text']")).sendKeys("Test Income") + addRow.findElement(By.cssSelector("input[type='number']")).sendKeys("500") + click(addRow, "Add") + + rows(card).exists(_.getText.contains("Test Income")) shouldBe true + } + + it should "pay expense with default amount" in { + ensurePeriodExists() + addPlannedExpense("Pay Test Expense", 100.00) + + driver.get(s"$baseUrl/budget") + waitForPage("Budget") + + val card = findCard("Planned Items") + val pendingRow = card.findElement(By.xpath(".//tr[.//td[contains(text(),'Pay Test Expense')]]")) + + click(pendingRow, "Pay") + click(card.findElement(By.cssSelector("tr.table-info")), "Save") + + card.getText should include("Paid") + } + + it should "pay expense with overridden amount" in { + ensurePeriodExists() + addPlannedExpense("Override Pay Expense", 100.00) + + driver.get(s"$baseUrl/budget") + waitForPage("Budget") + + val card = findCard("Planned Items") + val pendingRow = card.findElement(By.xpath(".//tr[.//td[contains(text(),'Override Pay Expense')]]")) + + click(pendingRow, "Pay") + val editRow = card.findElement(By.cssSelector("tr.table-info")) + val input = editRow.findElement(By.cssSelector("input[type='number']")) + input.clear() + input.sendKeys("99.99") + click(editRow, "Save") + + card.getText should include("99.99") + } + + it should "add and delete an estimated expense" in { + ensurePeriodExists() + + driver.get(s"$baseUrl/budget") + waitForPage("Budget") + + val card = findCard("Estimated Expenses") + click(card, "+ Add") + + val addRow = card.findElement(By.cssSelector("tr.table-primary")) + addRow.findElement(By.cssSelector("input[type='text']")).sendKeys("To Delete Expense") + addRow.findElement(By.cssSelector("input[type='number']")).sendKeys("100") + click(addRow, "Add") + + rows(card).exists(_.getText.contains("To Delete Expense")) shouldBe true + + val toDelete = card.findElement(By.xpath(".//tr[.//td[contains(text(),'To Delete Expense')]]")) + click(toDelete, "Edit") + click(card.findElement(By.cssSelector("tr.table-warning")), "Del") + + rows(card).exists(_.getText.contains("To Delete Expense")) shouldBe false + } + + // ============ Planned Savings ============ + + it should "show planned savings card" in { + ensurePeriodExists() + + driver.get(s"$baseUrl/budget") + waitForPage("Budget") + + val cardTexts = driver.findElements(By.cssSelector(".card")).asScala.map(_.getText).toList + cardTexts.exists(_.contains("Planned Savings")) shouldBe true + } + + it should "show savings accounts with targets" in { + ensurePeriodExists() + addSavingsAccount("Budget Savings Test", Some(500)) + + driver.get(s"$baseUrl/budget") + waitForPage("Budget") + + val card = findCard("Planned Savings") + card.getText should include("Budget Savings Test") + card.getText should include("Target") + card.getText should include("Saved") + card.getText should include("Remaining") + } + + it should "expand savings account to show transactions" in { + ensurePeriodExists() + addSavingsAccount("Expand Test Savings", Some(500)) + + driver.get(s"$baseUrl/budget") + waitForPage("Budget") + + val card = findCard("Planned Savings") + // Find a savings account row and click to expand + val savingsRow = card.findElement(By.xpath(".//tr[.//td[contains(text(),'Expand Test Savings')]]")) + savingsRow.click() + Thread.sleep(300) + + // Should see "+ Add" button in expanded view + card.findElement(By.xpath(".//button[contains(text(),'+ Add')]")).isDisplayed shouldBe true + } + + it should "add a savings transaction" in { + ensurePeriodExists() + addSavingsAccount("Add Txn Savings", Some(500)) + + driver.get(s"$baseUrl/budget") + waitForPage("Budget") + + val card = findCard("Planned Savings") + // Expand the savings account + val savingsRow = card.findElement(By.xpath(".//tr[.//td[contains(text(),'Add Txn Savings')]]")) + savingsRow.click() + Thread.sleep(300) + + // Click + Add to show transaction form + click(card, "+ Add") + + // Fill in transaction + val addRow = card.findElement(By.cssSelector("tr.table-info")) + addRow.findElement(By.cssSelector("input[type='text']")).sendKeys("Test deposit") + val amountInput = addRow.findElement(By.cssSelector("input[type='number']")) + amountInput.clear() + amountInput.sendKeys("50") + click(addRow, "Add") + + // Transaction should appear + card.getText should include("Test deposit") + } + + it should "delete a savings transaction" in { + ensurePeriodExists() + addSavingsAccount("Delete Txn Savings", Some(500)) + + driver.get(s"$baseUrl/budget") + waitForPage("Budget") + + val card = findCard("Planned Savings") + // Expand the savings account + val savingsRow = card.findElement(By.xpath(".//tr[.//td[contains(text(),'Delete Txn Savings')]]")) + savingsRow.click() + Thread.sleep(300) + + // Add a transaction to delete + click(card, "+ Add") + val addRow = card.findElement(By.cssSelector("tr.table-info")) + addRow.findElement(By.cssSelector("input[type='text']")).sendKeys("To delete txn") + val amountInput = addRow.findElement(By.cssSelector("input[type='number']")) + amountInput.clear() + amountInput.sendKeys("10") + click(addRow, "Add") + + card.getText should include("To delete txn") + + // Find and delete the transaction + val txnRow = card.findElement(By.xpath(".//tr[.//td[contains(text(),'To delete txn')]]")) + click(txnRow, "×") + + card.getText should not include "To delete txn" + } + + it should "collapse expanded savings account" in { + ensurePeriodExists() + addSavingsAccount("Collapse Test Savings", Some(500)) + + driver.get(s"$baseUrl/budget") + waitForPage("Budget") + + val card = findCard("Planned Savings") + // Expand + card.findElement(By.xpath(".//tr[.//td[contains(text(),'Collapse Test Savings')]]")).click() + Thread.sleep(300) + + card.findElements(By.xpath(".//button[contains(text(),'+ Add')]")).size() shouldBe 1 + + // Collapse - need to re-find element as DOM was updated + card.findElement(By.xpath(".//tr[.//td[contains(text(),'Collapse Test Savings')]]")).click() + Thread.sleep(300) + + card.findElements(By.xpath(".//button[contains(text(),'+ Add')]")).size() shouldBe 0 + } + + it should "show remaining to save in footer" in { + ensurePeriodExists() + addSavingsAccount("Footer Savings Test", Some(500)) + + driver.get(s"$baseUrl/budget") + waitForPage("Budget") + + val card = findCard("Planned Savings") + val footerText = card.findElement(By.cssSelector(".card-footer")).getText + footerText should include("Remaining to Save") + } +} diff --git a/e2e/src/test/scala/ssbudget/e2e/CurrencySettingsSpec.scala b/e2e/src/test/scala/ssbudget/e2e/CurrencySettingsSpec.scala new file mode 100644 index 0000000..7caaf28 --- /dev/null +++ b/e2e/src/test/scala/ssbudget/e2e/CurrencySettingsSpec.scala @@ -0,0 +1,187 @@ +package ssbudget.e2e + +import org.openqa.selenium.By +import scala.jdk.CollectionConverters.* + +class CurrencySettingsSpec extends E2ESpec { + + "Settings page" should "show Currencies card with PLN and EUR" in { + driver.get(s"$baseUrl/settings") + waitFor.until(_ => driver.findElement(By.tagName("h2")).getText.contains("Settings")) + + val currenciesCard = findCardByH5("Currencies") + currenciesCard.isDisplayed shouldBe true + + val cardText = currenciesCard.getText + cardText should include("PLN") + cardText should include("Polish Zloty") + cardText should include("EUR") + cardText should include("Euro") + cardText should include("Primary") + } + + it should "show PLN as primary currency by default" in { + driver.get(s"$baseUrl/settings") + waitFor.until(_ => driver.findElement(By.tagName("h2")).getText.contains("Settings")) + + val currenciesCard = findCardByH5("Currencies") + val plnRow = currenciesCard.findElement(By.xpath(".//tr[contains(.,'PLN')]")) + plnRow.getText should include("Primary") + } + + it should "have Refresh Rates button" in { + driver.get(s"$baseUrl/settings") + waitFor.until(_ => driver.findElement(By.tagName("h2")).getText.contains("Settings")) + + val currenciesCard = findCardByH5("Currencies") + val refreshButton = currenciesCard.findElement(By.xpath(".//button[contains(.,'Refresh Rates')]")) + refreshButton.isDisplayed shouldBe true + } + + it should "refresh exchange rates and display them" in { + driver.get(s"$baseUrl/settings") + waitFor.until(_ => driver.findElement(By.tagName("h2")).getText.contains("Settings")) + + val currenciesCard = findCardByH5("Currencies") + + // Click refresh rates button + click(currenciesCard, "Refresh Rates") + Thread.sleep(2000) // Wait for API call to external service + + // Wait for success message + waitFor.until { _ => + val alerts = driver.findElements(By.cssSelector(".alert-success")).asScala + alerts.exists(_.getText.contains("Exchange rates refreshed")) + } + + // Verify EUR row has a rate displayed (not N/A) + val eurRow = currenciesCard.findElement(By.xpath(".//tr[contains(.,'EUR')]")) + val eurRowText = eurRow.getText + eurRowText should not include "N/A" + + // Verify the rate is a valid number (should be something like "4.1234") + val rateCell = eurRow.findElement(By.cssSelector("td.text-end.font-monospace")) + val rateText = rateCell.getText.trim + rateText should fullyMatch regex """\d+\.\d{4}""" + } + + it should "add a new currency" in { + driver.get(s"$baseUrl/settings") + waitFor.until(_ => driver.findElement(By.tagName("h2")).getText.contains("Settings")) + + val currenciesCard = findCardByH5("Currencies") + val initialRows = currenciesCard.findElements(By.cssSelector("tbody tr")).asScala.size + + val input = currenciesCard.findElement(By.cssSelector("input[type='text']")) + input.sendKeys("USD") + click(currenciesCard, "Add Currency") + Thread.sleep(500) + + val newRows = currenciesCard.findElements(By.cssSelector("tbody tr")).asScala.size + newRows shouldBe (initialRows + 1) + currenciesCard.getText should include("USD") + currenciesCard.getText should include("US Dollar") + } + + it should "set a different currency as primary" in { + // First ensure USD is enabled + driver.get(s"$baseUrl/settings") + waitFor.until(_ => driver.findElement(By.tagName("h2")).getText.contains("Settings")) + + val currenciesCard = findCardByH5("Currencies") + + // Add USD if not present + if !currenciesCard.getText.contains("USD") then { + val input = currenciesCard.findElement(By.cssSelector("input[type='text']")) + input.sendKeys("USD") + click(currenciesCard, "Add Currency") + Thread.sleep(500) + } + + // Set USD as primary + val usdRow = currenciesCard.findElement(By.xpath(".//tr[contains(.,'USD')]")) + click(usdRow, "Set Primary") + Thread.sleep(500) + + // Verify USD is now primary + driver.get(s"$baseUrl/settings") + waitFor.until(_ => driver.findElement(By.tagName("h2")).getText.contains("Settings")) + + val updatedCard = findCardByH5("Currencies") + val updatedUsdRow = updatedCard.findElement(By.xpath(".//tr[contains(.,'USD')]")) + updatedUsdRow.getText should include("Primary") + + // The old primary (PLN) should no longer have the primary badge in its row + val plnRow = updatedCard.findElement(By.xpath(".//tr[contains(.,'PLN')]")) + plnRow.findElements(By.xpath(".//span[contains(@class,'text-bg-primary')]")).asScala.size shouldBe 0 + + // Restore PLN as primary for other tests + click(plnRow, "Set Primary") + Thread.sleep(500) + } + + it should "remove a non-primary currency" in { + driver.get(s"$baseUrl/settings") + waitFor.until(_ => driver.findElement(By.tagName("h2")).getText.contains("Settings")) + + val currenciesCard = findCardByH5("Currencies") + + // Add GBP to remove + if !currenciesCard.getText.contains("GBP") then { + val input = currenciesCard.findElement(By.cssSelector("input[type='text']")) + input.sendKeys("GBP") + click(currenciesCard, "Add Currency") + Thread.sleep(500) + } + + val rowsBefore = currenciesCard.findElements(By.cssSelector("tbody tr")).asScala.size + val gbpRow = currenciesCard.findElement(By.xpath(".//tr[contains(.,'GBP')]")) + click(gbpRow, "Remove") + Thread.sleep(500) + + val rowsAfter = currenciesCard.findElements(By.cssSelector("tbody tr")).asScala.size + rowsAfter shouldBe (rowsBefore - 1) + currenciesCard.getText should not include "GBP" + } + + it should "not show remove button for primary currency" in { + driver.get(s"$baseUrl/settings") + waitFor.until(_ => driver.findElement(By.tagName("h2")).getText.contains("Settings")) + + val currenciesCard = findCardByH5("Currencies") + val plnRow = currenciesCard.findElement(By.xpath(".//tr[contains(.,'PLN') and contains(.,'Primary')]")) + val removeButtons = plnRow.findElements(By.xpath(".//button[contains(.,'Remove')]")).asScala + removeButtons.size shouldBe 0 + } + + it should "show enabled currencies in account creation dropdown" in { + // First add USD currency if not present + driver.get(s"$baseUrl/settings") + waitFor.until(_ => driver.findElement(By.tagName("h2")).getText.contains("Settings")) + + val currenciesCard = findCardByH5("Currencies") + if !currenciesCard.getText.contains("USD") then { + val input = currenciesCard.findElement(By.cssSelector("input[type='text']")) + input.sendKeys("USD") + click(currenciesCard, "Add Currency") + Thread.sleep(500) + } + + // Navigate to accounts page + driver.get(s"$baseUrl/accounts") + waitForPage("Accounts") + + val bankCard = findCard("Bank Accounts") + click(bankCard, "+ Add") + + val addRow = bankCard.findElement(By.cssSelector("tbody tr.table-primary")) + val dropdown = addRow.findElement(By.cssSelector("select")) + val options = dropdown.findElements(By.tagName("option")).asScala.map(_.getText).toList + + options should contain("PLN") + options should contain("EUR") + options should contain("USD") + + click(addRow, "Cancel") + } +} diff --git a/e2e/src/test/scala/ssbudget/e2e/DashboardSpec.scala b/e2e/src/test/scala/ssbudget/e2e/DashboardSpec.scala new file mode 100644 index 0000000..60b3ca5 --- /dev/null +++ b/e2e/src/test/scala/ssbudget/e2e/DashboardSpec.scala @@ -0,0 +1,96 @@ +package ssbudget.e2e + +import org.openqa.selenium.{By, JavascriptExecutor} +import scala.jdk.CollectionConverters.* + +class DashboardSpec extends E2ESpec { + + "Dashboard" should "load and show summary panel" in { + ensurePeriodExists() + addBankAccount("Test Account") + + driver.get(baseUrl) + waitForPage("Dashboard") + + val cardTexts = driver.findElements(By.cssSelector(".card")).asScala.map(_.getText).toList + cardTexts.exists(_.contains("BALANCE")) shouldBe true + cardTexts.exists(_.contains("FREE")) shouldBe true + cardTexts.exists(_.contains("DAYS")) shouldBe true + } + + it should "update account balance via bulk edit" in { + ensurePeriodExists() + addBankAccount("Balance Test Account") + + driver.get(baseUrl) + waitForPage("Dashboard") + + val card = findCard("Accounts") + click(card, "Edit Balances") + val input = card.findElements(By.cssSelector("input[type='number']")).asScala.head + input.clear() + input.sendKeys("5000.00") + click(card, "Save All") + + Thread.sleep(300) + card.getText should include("5,000") + } + + it should "cancel balance edit without saving" in { + ensurePeriodExists() + addBankAccount("Cancel Test Account") + + driver.get(baseUrl) + waitForPage("Dashboard") + + val card = findCard("Accounts") + val initialTotal = card.findElement(By.cssSelector(".card-footer .font-monospace")).getText + + click(card, "Edit Balances") + card.findElement(By.cssSelector("input[type='number']")).sendKeys("999999") + click(card, "Cancel") + + card.findElement(By.cssSelector(".card-footer .font-monospace")).getText shouldBe initialTotal + } + + it should "copy summary to clipboard" in { + ensurePeriodExists() + addBankAccount("Clipboard Test Account") + + driver.get(baseUrl) + waitForPage("Dashboard") + + // Grant clipboard permissions via CDP + val cdpDriver = driver.asInstanceOf[org.openqa.selenium.chromium.HasCdp] + cdpDriver.executeCdpCommand( + "Browser.grantPermissions", + java.util.Map.of( + "permissions", + java.util.List.of("clipboardReadWrite", "clipboardSanitizedWrite"), + "origin", + baseUrl, + ), + ) + + val btn = driver.findElement(By.xpath("//button[contains(text(),'Copy Summary')]")) + btn.click() + Thread.sleep(500) + + // Button should show "Copied!" feedback + btn.getText shouldBe "Copied!" + + // Read clipboard via JavaScript + val js = driver.asInstanceOf[JavascriptExecutor] + val clipboard = js + .executeAsyncScript( + """var callback = arguments[arguments.length - 1]; + |navigator.clipboard.readText().then(callback).catch(function(e) { callback('ERROR: ' + e.message); });""".stripMargin, + ) + .asInstanceOf[String] + + clipboard should include("Budget Update") + clipboard should include("Balance:") + clipboard should include("Free:") + clipboard should include("Daily:") + } +} diff --git a/e2e/src/test/scala/ssbudget/e2e/DatabaseSpec.scala b/e2e/src/test/scala/ssbudget/e2e/DatabaseSpec.scala new file mode 100644 index 0000000..e1793a6 --- /dev/null +++ b/e2e/src/test/scala/ssbudget/e2e/DatabaseSpec.scala @@ -0,0 +1,169 @@ +package ssbudget.e2e + +import org.openqa.selenium.By +import org.openqa.selenium.chrome.ChromeOptions + +import java.io.File +import java.nio.file.{Files, Path} +import scala.jdk.CollectionConverters.* + +class DatabaseSpec extends E2ESpec { + + private var downloadDir: Path = _ + + override def beforeEach(): Unit = { + // Create temp download directory + downloadDir = Files.createTempDirectory("ssbudget-e2e-download-") + + // Configure Chrome to download to our temp directory + val options = new ChromeOptions() + options.addArguments("--headless", "--no-sandbox", "--disable-dev-shm-usage") + options.setExperimentalOption( + "prefs", + java.util.Map.of( + "download.default_directory", + downloadDir.toAbsolutePath.toString, + "download.prompt_for_download", + false, + "download.directory_upgrade", + true, + ), + ) + + import io.github.bonigarcia.wdm.WebDriverManager + import org.openqa.selenium.chrome.ChromeDriver + WebDriverManager.chromedriver().setup() + driver = new ChromeDriver(options) + driver.manage().timeouts().implicitlyWait(java.time.Duration.ofSeconds(10)) + } + + override def afterEach(): Unit = { + super.afterEach() + // Cleanup download directory + if downloadDir != null then { + Files.walk(downloadDir).sorted(java.util.Comparator.reverseOrder()).forEach(Files.delete(_)) + } + } + + "Database export" should "download a valid SQLite file" in { + // Setup: create an account so we have data + addBankAccount("Export Test Account") + + // Go to settings and click export + driver.get(s"$baseUrl/settings") + waitFor.until(_ => driver.findElement(By.tagName("h2")).getText.contains("Settings")) + + val dataCard = findCardByH5("Data") + val exportLink = dataCard.findElement(By.xpath(".//a[contains(.,'Export Database')]")) + exportLink.click() + + // Wait for download to complete (check for .db file) + var downloadedFile: Option[File] = None + var attempts = 0 + while downloadedFile.isEmpty && attempts < 30 do { + Thread.sleep(500) + val files = downloadDir.toFile.listFiles() + if files != null then { + downloadedFile = files.find(f => f.getName.endsWith(".db") && !f.getName.endsWith(".crdownload")) + } + attempts += 1 + } + + downloadedFile shouldBe defined + val file = downloadedFile.get + + // Verify it's a valid SQLite file (check header) + val bytes = Files.readAllBytes(file.toPath) + bytes.length should be > 100 + val header = new String(bytes.take(16), "UTF-8") + header should startWith("SQLite format 3") + } + + "Database import" should "restore data from a backup" in { + // Step 1: Create initial data + ensurePeriodExists() + addBankAccount("Original Account") + addPlannedExpense("Original Expense", 100) + + // Step 2: Export the database + driver.get(s"$baseUrl/settings") + waitFor.until(_ => driver.findElement(By.tagName("h2")).getText.contains("Settings")) + + val dataCard = findCardByH5("Data") + val exportLink = dataCard.findElement(By.xpath(".//a[contains(.,'Export Database')]")) + exportLink.click() + + // Wait for download + var backupFile: Option[File] = None + var attempts = 0 + while backupFile.isEmpty && attempts < 30 do { + Thread.sleep(500) + val files = downloadDir.toFile.listFiles() + if files != null then { + backupFile = files.find(f => f.getName.endsWith(".db") && !f.getName.endsWith(".crdownload")) + } + attempts += 1 + } + backupFile shouldBe defined + + // Step 3: Add more data (this will be lost after restore) + addBankAccount("New Account After Backup") + addPlannedExpense("New Expense After Backup", 200) + + // Verify the new data exists + driver.get(s"$baseUrl/accounts") + waitForPage("Accounts") + val bankCardBefore = findCard("Bank Accounts") + bankCardBefore.getText should include("New Account After Backup") + + // Step 4: Import the backup + driver.get(s"$baseUrl/settings") + waitFor.until(_ => driver.findElement(By.tagName("h2")).getText.contains("Settings")) + + val dataCardAgain = findCardByH5("Data") + val fileInput = dataCardAgain.findElement(By.cssSelector("input[type='file']")) + + // Send the backup file path to the hidden input + fileInput.sendKeys(backupFile.get.getAbsolutePath) + + // Wait for import to complete (success message should appear) + Thread.sleep(2000) + val alerts = driver.findElements(By.cssSelector(".alert-success")).asScala + alerts.exists(_.getText.contains("imported successfully")) shouldBe true + + // Step 5: Verify the data was restored (new data should be gone, original should exist) + driver.get(s"$baseUrl/accounts") + waitForPage("Accounts") + Thread.sleep(500) + + val bankCardAfter = findCard("Bank Accounts") + bankCardAfter.getText should include("Original Account") + bankCardAfter.getText should not include "New Account After Backup" + + driver.get(s"$baseUrl/budget") + waitForPage("Budget") + Thread.sleep(500) + + val plannedCard = findCard("Planned Items") + plannedCard.getText should include("Original Expense") + plannedCard.getText should not include "New Expense After Backup" + } + + it should "show error for invalid file" in { + driver.get(s"$baseUrl/settings") + waitFor.until(_ => driver.findElement(By.tagName("h2")).getText.contains("Settings")) + + // Create an invalid file (not SQLite) + val invalidFile = Files.createTempFile(downloadDir, "invalid", ".db") + Files.write(invalidFile, "this is not a sqlite file".getBytes) + + val dataCard = findCardByH5("Data") + val fileInput = dataCard.findElement(By.cssSelector("input[type='file']")) + fileInput.sendKeys(invalidFile.toAbsolutePath.toString) + + // Wait for error message + Thread.sleep(2000) + val alerts = driver.findElements(By.cssSelector(".alert-danger")).asScala + alerts.exists(_.getText.toLowerCase.contains("invalid")) shouldBe true + } +} diff --git a/e2e/src/test/scala/ssbudget/e2e/E2ESpec.scala b/e2e/src/test/scala/ssbudget/e2e/E2ESpec.scala new file mode 100644 index 0000000..a9b7baa --- /dev/null +++ b/e2e/src/test/scala/ssbudget/e2e/E2ESpec.scala @@ -0,0 +1,182 @@ +package ssbudget.e2e + +import io.github.bonigarcia.wdm.WebDriverManager +import org.openqa.selenium.{By, WebDriver, WebElement} +import org.openqa.selenium.chrome.{ChromeDriver, ChromeOptions} +import org.openqa.selenium.support.ui.{ExpectedConditions, WebDriverWait} +import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach} +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import java.time.Duration +import scala.jdk.CollectionConverters.* + +trait E2ESpec extends AnyFlatSpec with Matchers with BeforeAndAfterAll with BeforeAndAfterEach { + + import scala.compiletime.uninitialized + protected var driver: WebDriver = uninitialized + + // If E2E_BASE_URL is set, use it (external servers) + // Otherwise, TestServers will be started by E2ESuite + protected def baseUrl: String = + sys.env.getOrElse("E2E_BASE_URL", TestServers.frontendUrl) + + override def beforeAll(): Unit = { + // Start servers if not using external servers and not already started + if sys.env.get("E2E_BASE_URL").isEmpty then { + TestServers.startAll() + } + WebDriverManager.chromedriver().setup() + } + + override def afterAll(): Unit = { + // Servers are stopped by E2ESuite if running via suite, + // or need to be stopped here if running individual spec + // TestServers.stopAll() is idempotent + } + + override def beforeEach(): Unit = { + val options = new ChromeOptions() + options.addArguments("--headless", "--no-sandbox", "--disable-dev-shm-usage") + driver = new ChromeDriver(options) + driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(10)) + } + + override def afterEach(): Unit = if driver != null then driver.quit() + + protected def waitFor: WebDriverWait = new WebDriverWait(driver, Duration.ofSeconds(10)) + + protected def waitForPage(title: String): Unit = { + waitFor.until(ExpectedConditions.textToBePresentInElementLocated(By.tagName("h4"), title)) + Thread.sleep(300) + } + + protected def findCard(headerText: String): WebElement = + driver.findElement(By.xpath(s"//span[text()='$headerText']/ancestor::div[contains(@class,'card')]")) + + protected def findCardByDiv(headerText: String): WebElement = + driver.findElement(By.xpath(s"//div[text()='$headerText']/ancestor::div[contains(@class,'card')]")) + + protected def findCardByH5(headerText: String): WebElement = + driver.findElement(By.xpath(s"//h5[text()='$headerText']/ancestor::div[contains(@class,'card')]")) + + protected def rows(parent: WebElement): List[WebElement] = + parent.findElements(By.cssSelector("tbody tr")).asScala.toList + + protected def click(parent: WebElement, buttonText: String): Unit = { + // Use . instead of text() to match text in child elements (like spans) + parent.findElement(By.xpath(s".//button[contains(.,'$buttonText')]")).click() + Thread.sleep(300) + } + + protected def clickIfExists(parent: WebElement, buttonText: String): Boolean = { + // Use . instead of text() to match text in child elements (like spans) + val buttons = parent.findElements(By.xpath(s".//button[contains(.,'$buttonText')]")).asScala + if buttons.nonEmpty then { + buttons.head.click() + Thread.sleep(300) + true + } else false + } + + // ============ Setup Helpers ============ + + /** Ensure there's a current period, starting one if needed */ + protected def ensurePeriodExists(): Unit = { + driver.get(s"$baseUrl/periods") + waitForPage("Periods") + + val currentPeriodCard = findCardByDiv("Current Period") + // Check if "No active period" message is shown - only then click button + val cardText = currentPeriodCard.getText + if cardText.contains("No active period") then { + click(currentPeriodCard, "Start New Period") + Thread.sleep(500) + // Refresh to see updated state + driver.get(s"$baseUrl/periods") + waitForPage("Periods") + } + // Otherwise period already exists, nothing to do + } + + /** Add a bank account and return to the specified page */ + protected def addBankAccount(name: String, currency: String = "PLN"): Unit = { + driver.get(s"$baseUrl/accounts") + waitForPage("Accounts") + + val bankCard = findCard("Bank Accounts") + click(bankCard, "+ Add") + + val addRow = bankCard.findElement(By.cssSelector("tbody tr.table-primary")) + addRow.findElement(By.cssSelector("input[type='text']")).sendKeys(name) + // Select currency if not PLN + if currency != "PLN" then { + val select = addRow.findElement(By.cssSelector("select")) + select.findElement(By.xpath(s".//option[text()='$currency']")).click() + } + click(addRow, "Add") + Thread.sleep(300) + } + + /** Add a savings account */ + protected def addSavingsAccount(name: String, targetAmount: Option[Int] = None): Unit = { + driver.get(s"$baseUrl/accounts") + waitForPage("Accounts") + + val savingsCard = findCard("Savings Accounts") + click(savingsCard, "+ Add") + + val addRow = savingsCard.findElement(By.cssSelector("tbody tr.table-success")) + addRow.findElement(By.cssSelector("input[type='text']")).sendKeys(name) + targetAmount.foreach { amount => + addRow.findElement(By.cssSelector("input[type='number']")).sendKeys(amount.toString) + } + click(addRow, "Add") + Thread.sleep(300) + } + + /** Add a planned expense */ + protected def addPlannedExpense(name: String, amount: Double): Unit = { + driver.get(s"$baseUrl/budget") + waitForPage("Budget") + + val card = findCard("Planned Items") + click(card, "+ Expense") + + val addRow = card.findElement(By.cssSelector("tr.table-primary")) + addRow.findElement(By.cssSelector("input[type='text']")).sendKeys(name) + addRow.findElement(By.cssSelector("input[type='number']")).sendKeys(amount.toString) + click(addRow, "Add") + Thread.sleep(300) + } + + /** Add a planned income */ + protected def addPlannedIncome(name: String, amount: Double): Unit = { + driver.get(s"$baseUrl/budget") + waitForPage("Budget") + + val card = findCard("Planned Items") + click(card, "+ Income") + + val addRow = card.findElement(By.cssSelector("tr.table-primary")) + addRow.findElement(By.cssSelector("input[type='text']")).sendKeys(name) + addRow.findElement(By.cssSelector("input[type='number']")).sendKeys(amount.toString) + click(addRow, "Add") + Thread.sleep(300) + } + + /** Add an estimated expense */ + protected def addEstimatedExpense(name: String, amount: Double): Unit = { + driver.get(s"$baseUrl/budget") + waitForPage("Budget") + + val card = findCard("Estimated Expenses") + click(card, "+ Add") + + val addRow = card.findElement(By.cssSelector("tr.table-primary")) + addRow.findElement(By.cssSelector("input[type='text']")).sendKeys(name) + addRow.findElement(By.cssSelector("input[type='number']")).sendKeys(amount.toString) + click(addRow, "Add") + Thread.sleep(300) + } +} diff --git a/e2e/src/test/scala/ssbudget/e2e/E2ESuite.scala b/e2e/src/test/scala/ssbudget/e2e/E2ESuite.scala new file mode 100644 index 0000000..f11702f --- /dev/null +++ b/e2e/src/test/scala/ssbudget/e2e/E2ESuite.scala @@ -0,0 +1,38 @@ +package ssbudget.e2e + +import org.scalatest.{BeforeAndAfterAll, Suites} + +/** Master test suite that manages server lifecycle. Run with: sbt "e2e/testOnly ssbudget.e2e.E2ESuite" + * + * This suite: + * 1. Starts backend on a random port (in-process) + * 2. Starts frontend/vite on a random port (proxying to backend) + * 3. Runs all E2E test specs + * 4. Stops all servers + */ +class E2ESuite + extends Suites( + new DashboardSpec, + new AccountsPageSpec, + new BudgetPageSpec, + new PeriodsPageSpec, + new CurrencySettingsSpec, + new DatabaseSpec, + ) + with BeforeAndAfterAll { + + override def beforeAll(): Unit = { + // Only start servers if E2E_BASE_URL is not set (i.e., not using external servers) + if sys.env.get("E2E_BASE_URL").isEmpty then { + TestServers.startAll() + } + super.beforeAll() + } + + override def afterAll(): Unit = { + super.afterAll() + if sys.env.get("E2E_BASE_URL").isEmpty then { + TestServers.stopAll() + } + } +} diff --git a/e2e/src/test/scala/ssbudget/e2e/PasskeySpec.scala b/e2e/src/test/scala/ssbudget/e2e/PasskeySpec.scala new file mode 100644 index 0000000..8c5b370 --- /dev/null +++ b/e2e/src/test/scala/ssbudget/e2e/PasskeySpec.scala @@ -0,0 +1,238 @@ +package ssbudget.e2e + +import io.github.bonigarcia.wdm.WebDriverManager +import org.openqa.selenium.By +import org.openqa.selenium.chrome.{ChromeDriver, ChromeOptions} +import org.openqa.selenium.logging.{LogType, LoggingPreferences} +import java.util.logging.Level +import org.openqa.selenium.support.ui.{ExpectedConditions, WebDriverWait} +import org.openqa.selenium.virtualauthenticator.{HasVirtualAuthenticator, VirtualAuthenticatorOptions} +import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach} +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import java.time.Duration + +/** Passkey e2e tests - tests WebAuthn/passkey registration and authentication. Run with: sbt "e2e/testOnly ssbudget.e2e.PasskeySpec" + */ +class PasskeySpec extends AnyFlatSpec with Matchers with BeforeAndAfterAll with BeforeAndAfterEach { + + import scala.compiletime.uninitialized + protected var driver: ChromeDriver = uninitialized + + protected def baseUrl: String = AuthTestServers.frontendUrl + + override def beforeAll(): Unit = { + AuthTestServers.startAll() + WebDriverManager.chromedriver().setup() + } + + override def afterAll(): Unit = { + AuthTestServers.stopAll() + } + + override def beforeEach(): Unit = { + val options = new ChromeOptions() + options.addArguments("--headless", "--no-sandbox", "--disable-dev-shm-usage", "--window-size=1920,1080") + // Enable virtual authenticator support + options.addArguments("--enable-features=WebAuthenticationRemoteDesktopSupport") + // Enable console logging + val logPrefs = new LoggingPreferences() + logPrefs.enable(LogType.BROWSER, Level.ALL) + options.setCapability("goog:loggingPrefs", logPrefs) + driver = new ChromeDriver(options) + driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(10)) + } + + override def afterEach(): Unit = { + if driver != null then driver.quit() + AuthTestServers.resetDatabase() + } + + protected def waitFor: WebDriverWait = new WebDriverWait(driver, Duration.ofSeconds(10)) + + /** Setup password and login (prerequisite for passkey tests) */ + private def setupAndLogin(): Unit = { + driver.get(baseUrl) + waitFor.until(ExpectedConditions.textToBePresentInElementLocated(By.tagName("h3"), "SSBudget Setup")) + driver.findElement(By.id("password")).sendKeys("testpassword123") + driver.findElement(By.id("confirm")).sendKeys("testpassword123") + driver.findElement(By.xpath("//button[text()='Create Password']")).click() + waitFor.until(ExpectedConditions.textToBePresentInElementLocated(By.tagName("h4"), "Dashboard")) + Thread.sleep(500) + } + + /** Add a virtual authenticator to the browser session */ + private def addVirtualAuthenticator(): Unit = { + val authenticatorOptions = new VirtualAuthenticatorOptions() + .setTransport(VirtualAuthenticatorOptions.Transport.INTERNAL) + .setProtocol(VirtualAuthenticatorOptions.Protocol.CTAP2) + .setHasResidentKey(true) + .setHasUserVerification(true) + .setIsUserVerified(true) + + driver.asInstanceOf[HasVirtualAuthenticator].addVirtualAuthenticator(authenticatorOptions) + } + + /** Navigate to settings page */ + private def goToSettings(): Unit = { + // Wait for navbar to be visible first + waitFor.until(ExpectedConditions.visibilityOfElementLocated(By.className("navbar"))) + // Use link text or contains href since absoluteUrlForPage returns full URL + driver.findElement(By.linkText("Settings")).click() + waitFor.until(ExpectedConditions.textToBePresentInElementLocated(By.tagName("h2"), "Settings")) + Thread.sleep(300) + } + + /** Logout the current session */ + private def logout(): Unit = { + // Logout button is in the navbar on any page + waitFor.until(ExpectedConditions.visibilityOfElementLocated(By.className("navbar"))) + driver.findElement(By.xpath("//nav//button[text()='Logout']")).click() + waitFor.until(ExpectedConditions.textToBePresentInElementLocated(By.tagName("h3"), "SSBudget")) + Thread.sleep(300) + } + + behavior of "Passkey Authentication" + + it should "show Add Passkey button on settings page" in { + addVirtualAuthenticator() + setupAndLogin() + goToSettings() + + // Should see passkey section with Add Passkey button + waitFor.until(ExpectedConditions.visibilityOfElementLocated(By.xpath("//button[text()='Add Passkey']"))) + + // Should show message about no passkeys + driver.findElement(By.xpath("//*[contains(text(),'No passkeys registered')]")).isDisplayed shouldBe true + } + + it should "register a passkey" in { + addVirtualAuthenticator() + setupAndLogin() + goToSettings() + + // Enter passkey name and click Add Passkey + val passkeyNameInput = driver.findElement(By.xpath("//input[@placeholder='Passkey name (optional)']")) + passkeyNameInput.sendKeys("Test Passkey") + driver.findElement(By.xpath("//button[text()='Add Passkey']")).click() + + // Wait for success message + waitFor.until(ExpectedConditions.textToBePresentInElementLocated(By.className("alert-success"), "Passkey added")) + + // Should see the passkey in the list + waitFor.until(ExpectedConditions.visibilityOfElementLocated(By.xpath("//*[contains(text(),'Test Passkey')]"))) + } + + it should "register a passkey without name" in { + addVirtualAuthenticator() + setupAndLogin() + goToSettings() + + // Click Add Passkey without entering a name + driver.findElement(By.xpath("//button[text()='Add Passkey']")).click() + + // Wait for success message + waitFor.until(ExpectedConditions.textToBePresentInElementLocated(By.className("alert-success"), "Passkey added")) + + // Should see the passkey in the list (unnamed) + waitFor.until(ExpectedConditions.visibilityOfElementLocated(By.xpath("//*[contains(text(),'Unnamed passkey')]"))) + } + + it should "login with passkey" in { + addVirtualAuthenticator() + setupAndLogin() + goToSettings() + + // Register a passkey first + driver.findElement(By.xpath("//input[@placeholder='Passkey name (optional)']")).sendKeys("Login Test Passkey") + driver.findElement(By.xpath("//button[text()='Add Passkey']")).click() + waitFor.until(ExpectedConditions.textToBePresentInElementLocated(By.className("alert-success"), "Passkey added")) + + // Logout + logout() + + // Should see login page with passkey option + waitFor.until(ExpectedConditions.visibilityOfElementLocated(By.xpath("//button[contains(text(),'Sign in with Passkey')]"))) + + // Click sign in with passkey + driver.findElement(By.xpath("//button[contains(text(),'Sign in with Passkey')]")).click() + + // Give time for WebAuthn to complete + Thread.sleep(3000) + + // Check for any error message + val errors = driver.findElements(By.className("alert-danger")) + if errors.size() > 0 then { + val errorText = errors.get(0).getText + fail(s"Passkey login failed with error: $errorText") + } + + // Verify login success by checking that the navbar is visible (only shown when logged in) + // The app doesn't auto-redirect to / after login, so URL may still be /settings + waitFor.until(ExpectedConditions.visibilityOfElementLocated(By.className("navbar"))) + + // Verify we can navigate to Dashboard (confirming we're actually logged in) + driver.findElement(By.linkText("Dashboard")).click() + waitFor.until(ExpectedConditions.textToBePresentInElementLocated(By.tagName("h4"), "Dashboard")) + } + + it should "delete a passkey" in { + addVirtualAuthenticator() + setupAndLogin() + goToSettings() + + // Register a passkey first + driver.findElement(By.xpath("//input[@placeholder='Passkey name (optional)']")).sendKeys("Passkey To Delete") + driver.findElement(By.xpath("//button[text()='Add Passkey']")).click() + waitFor.until(ExpectedConditions.textToBePresentInElementLocated(By.className("alert-success"), "Passkey added")) + Thread.sleep(300) + + // Find and click the Delete button for this passkey + val passkeyItem = driver.findElement(By.xpath("//*[contains(text(),'Passkey To Delete')]/ancestor::li")) + passkeyItem.findElement(By.xpath(".//button[text()='Delete']")).click() + + // Wait for success message + waitFor.until(ExpectedConditions.textToBePresentInElementLocated(By.className("alert-success"), "Passkey deleted")) + + // Should show no passkeys message again + waitFor.until(ExpectedConditions.visibilityOfElementLocated(By.xpath("//*[contains(text(),'No passkeys registered')]"))) + } + + it should "not show passkey login option when no passkeys registered" in { + addVirtualAuthenticator() + setupAndLogin() + logout() + + // Should NOT see passkey login button (no passkeys registered) + val passkeyButtons = driver.findElements(By.xpath("//button[contains(text(),'Sign in with Passkey')]")) + passkeyButtons.size() shouldBe 0 + } + + it should "register multiple passkeys" in { + addVirtualAuthenticator() + setupAndLogin() + goToSettings() + + // Register first passkey + driver.findElement(By.xpath("//input[@placeholder='Passkey name (optional)']")).sendKeys("Passkey 1") + driver.findElement(By.xpath("//button[text()='Add Passkey']")).click() + waitFor.until(ExpectedConditions.textToBePresentInElementLocated(By.className("alert-success"), "Passkey added")) + Thread.sleep(500) + + // Dismiss success message + driver.findElement(By.xpath("//div[contains(@class,'alert-success')]//button[@class='btn-close']")).click() + Thread.sleep(300) + + // Register second passkey + val passkeyNameInput = driver.findElement(By.xpath("//input[@placeholder='Passkey name (optional)']")) + passkeyNameInput.clear() + passkeyNameInput.sendKeys("Passkey 2") + driver.findElement(By.xpath("//button[text()='Add Passkey']")).click() + waitFor.until(ExpectedConditions.textToBePresentInElementLocated(By.className("alert-success"), "Passkey added")) + + // Should see both passkeys in the list + driver.findElement(By.xpath("//*[contains(text(),'Passkey 1')]")).isDisplayed shouldBe true + driver.findElement(By.xpath("//*[contains(text(),'Passkey 2')]")).isDisplayed shouldBe true + } +} diff --git a/e2e/src/test/scala/ssbudget/e2e/PeriodsPageSpec.scala b/e2e/src/test/scala/ssbudget/e2e/PeriodsPageSpec.scala new file mode 100644 index 0000000..dafeb58 --- /dev/null +++ b/e2e/src/test/scala/ssbudget/e2e/PeriodsPageSpec.scala @@ -0,0 +1,74 @@ +package ssbudget.e2e + +import org.openqa.selenium.By +import scala.jdk.CollectionConverters.* + +class PeriodsPageSpec extends E2ESpec { + + "Periods page" should "load and show period cards" in { + driver.get(s"$baseUrl/periods") + waitForPage("Periods") + + val cardTexts = driver.findElements(By.cssSelector(".card")).asScala.map(_.getText).toList + cardTexts.exists(_.contains("Current Period")) shouldBe true + cardTexts.exists(_.contains("Period History")) shouldBe true + } + + it should "start new period when none exists" in { + driver.get(s"$baseUrl/periods") + waitForPage("Periods") + + val card = findCardByDiv("Current Period") + + // If no period, start one + if card.getText.contains("No active period") then { + click(card, "Start New Period") + Thread.sleep(500) + } + + // Now should have an active period with progress bar + val progressBar = card.findElement(By.cssSelector(".progress-bar")) + progressBar.getAttribute("style") should include("width:") + } + + it should "show progress bar for current period" in { + ensurePeriodExists() + + driver.get(s"$baseUrl/periods") + waitForPage("Periods") + + val card = findCardByDiv("Current Period") + val progressBar = card.findElement(By.cssSelector(".progress-bar")) + progressBar.getAttribute("style") should include("width:") + } + + it should "show at least one period in history when period exists" in { + ensurePeriodExists() + + driver.get(s"$baseUrl/periods") + waitForPage("Periods") + + val card = findCardByDiv("Period History") + val historyRows = rows(card) + historyRows.size should be >= 1 + + card.findElements(By.xpath(".//span[contains(@class,'badge') and contains(text(),'Active')]")).size() shouldBe 1 + } + + it should "close current period and start new one" in { + ensurePeriodExists() + + driver.get(s"$baseUrl/periods") + waitForPage("Periods") + + val historyCard = findCardByDiv("Period History") + val initialCount = rows(historyCard).size + val currentCard = findCardByDiv("Current Period") + + click(currentCard, "End Period & Start New") + Thread.sleep(500) + + rows(historyCard).size shouldBe (initialCount + 1) + historyCard.findElements(By.xpath(".//span[contains(@class,'badge') and contains(text(),'Active')]")).size() shouldBe 1 + } +} diff --git a/e2e/src/test/scala/ssbudget/e2e/TestServers.scala b/e2e/src/test/scala/ssbudget/e2e/TestServers.scala new file mode 100644 index 0000000..72045a6 --- /dev/null +++ b/e2e/src/test/scala/ssbudget/e2e/TestServers.scala @@ -0,0 +1,177 @@ +package ssbudget.e2e + +import cats.effect.IO +import cats.implicits.* +import cats.effect.unsafe.implicits.global +import com.comcast.ip4s.Port +import ssbudget.backend.ServerBuilder +import ssbudget.backend.db.{Database, Repositories} + +import java.io.File +import java.net.{HttpURLConnection, ServerSocket, URL} +import java.nio.file.{Files, Path} +import scala.sys.process.{Process, ProcessLogger} +import scala.util.{Try, Using} + +object TestServers { + + @volatile private var backendFiber: Option[cats.effect.FiberIO[Nothing]] = None + @volatile private var frontendProcess: Option[scala.sys.process.Process] = None + @volatile private var _backendPort: Int = 0 + @volatile private var _frontendPort: Int = 0 + @volatile private var dbPath: Option[Path] = None + + def backendPort: Int = _backendPort + def frontendPort: Int = _frontendPort + def frontendUrl: String = s"http://127.0.0.1:$_frontendPort" + + private def findAvailablePort(): Int = { + Using(new ServerSocket(0)) { socket => + socket.setReuseAddress(true) + socket.getLocalPort + }.get + } + + def startAll(): Unit = { + if backendFiber.isDefined then { + println("Servers already running") + return + } + + _backendPort = findAvailablePort() + _frontendPort = findAvailablePort() + + println(s"[E2E] Starting backend on port $_backendPort") + println(s"[E2E] Starting frontend on port $_frontendPort") + + startBackend() + startFrontend() + + waitForServer(s"http://127.0.0.1:$_backendPort/api/health", "Backend") + waitForServer(s"http://127.0.0.1:$_frontendPort", "Frontend") + + println("[E2E] All servers ready") + } + + private def startBackend(): Unit = { + val tempDb = Files.createTempFile("ssbudget-e2e-", ".db") + dbPath = Some(tempDb) + val jdbcUrl = s"jdbc:sqlite:${tempDb.toAbsolutePath}" + val port = Port.fromInt(_backendPort).get + val dbPathStr = tempDb.toAbsolutePath.toString + + val serverIO: IO[Nothing] = Database.migrateAndTransactor(jdbcUrl).use { xa => + val repos = Repositories.fromTransactor(xa) + ServerBuilder.build(repos, xa, port, testMode = true, dbPath = dbPathStr).useForever + } + + backendFiber = Some(serverIO.start.unsafeRunSync()) + } + + private def startFrontend(): Unit = { + // Find project root - user.dir is set to e2e directory via sbt javaOptions + val e2eDir = new File(System.getProperty("user.dir")) + val projectRoot = e2eDir.getParentFile + val frontendDir = new File(projectRoot, "frontend") + + println(s"[E2E] user.dir = ${System.getProperty("user.dir")}") + println(s"[E2E] Frontend dir = ${frontendDir.getAbsolutePath}") + + if !frontendDir.exists() then { + throw new RuntimeException(s"Frontend directory not found: ${frontendDir.getAbsolutePath}") + } + + // Check if vite config exists + val viteConfig = new File(frontendDir, "vite.config.e2e.mjs") + if !viteConfig.exists() then { + throw new RuntimeException(s"Vite config not found: ${viteConfig.getAbsolutePath}") + } + + val env = Seq( + "VITE_PORT" -> _frontendPort.toString, + "VITE_API_URL" -> s"http://localhost:$_backendPort", + ) + + // Use npx vite with config + val cmd = Seq("npx", "vite", "--config", "vite.config.e2e.mjs") + println(s"[E2E] Running: ${cmd.mkString(" ")} in ${frontendDir.getAbsolutePath}") + println(s"[E2E] Environment: VITE_PORT=$_frontendPort, VITE_API_URL=http://localhost:$_backendPort") + + val pb = Process(cmd, frontendDir, env*) + + // Capture all output for debugging + val logger = ProcessLogger( + out => println(s"[vite] $out"), + err => println(s"[vite-err] $err"), + ) + + frontendProcess = Some(pb.run(logger)) + } + + private def waitForServer(url: String, name: String, maxAttempts: Int = 60): Unit = { + var attempts = 0 + var ready = false + + while !ready && attempts < maxAttempts do { + val result = Try { + val connection = new URL(url).openConnection().asInstanceOf[HttpURLConnection] + connection.setConnectTimeout(2000) + connection.setReadTimeout(2000) + connection.setRequestMethod("GET") + try { + connection.connect() + val code = connection.getResponseCode + (code, code >= 200 && code < 500) + } finally { + connection.disconnect() + } + } + + result match { + case scala.util.Success((code, isReady)) => + if isReady then { + ready = true + } else { + if attempts % 10 == 0 then println(s"[E2E] $name returned $code, retrying...") + attempts += 1 + Thread.sleep(1000) + } + case scala.util.Failure(ex) => + if attempts % 10 == 0 then println(s"[E2E] $name connection failed: ${ex.getMessage}, retrying...") + attempts += 1 + Thread.sleep(1000) + } + } + + if !ready then { + throw new RuntimeException(s"$name failed to start at $url after $maxAttempts seconds") + } + + println(s"[E2E] $name is ready at $url") + } + + def stopAll(): Unit = { + println("[E2E] Stopping servers...") + + frontendProcess.foreach { p => + p.destroy() + Thread.sleep(200) + } + frontendProcess = None + + backendFiber.foreach { fiber => + fiber.cancel.unsafeRunSync() + } + backendFiber = None + + dbPath.foreach { path => + Try(Files.deleteIfExists(path)) + } + dbPath = None + + _backendPort = 0 + _frontendPort = 0 + + println("[E2E] Servers stopped") + } +} diff --git a/fly.toml b/fly.toml new file mode 100644 index 0000000..99b2ffb --- /dev/null +++ b/fly.toml @@ -0,0 +1,47 @@ +# fly.toml app configuration file generated for ssbudget on 2026-01-29T23:40:51+01:00 +# +# See https://fly.io/docs/reference/configuration/ for information about how to use this file. +# + +# First time setup: +# fly launch --no-deploy # Create app (skip if already exists) +# fly volumes create ssbudget_data --region fra --size 1 +# fly secrets set SSBUDGET_RP_ID=ssbudget.fly.dev +# fly secrets set SSBUDGET_RP_ORIGINS=https://ssbudget.fly.dev +# +# Deploy: +# ./build.sh # Build everything + Docker image +# fly deploy --local-only # Push local image to fly.io +# + +app = 'ssbudget' +primary_region = 'fra' + +[build] + +[env] + SSBUDGET_DB_PATH = '/data/ssbudget.db' + SSBUDGET_PORT = '8080' + SSBUDGET_RP_NAME = 'SSBudget' + SSBUDGET_STATIC_DIR = '/opt/docker/static' + +[[mounts]] + source = 'ssbudget_data' + destination = '/data' + +[http_service] + internal_port = 8080 + force_https = true + auto_stop_machines = 'stop' + auto_start_machines = true + min_machines_running = 0 + + [http_service.concurrency] + type = 'connections' + hard_limit = 25 + soft_limit = 20 + +[[vm]] + memory = '512mb' + cpus = 1 + memory_mb = 512 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..5215ebf --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + SSBudget + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..0789280 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1149 @@ +{ + "name": "ssbudget-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ssbudget-frontend", + "version": "0.1.0", + "dependencies": { + "bootstrap": "^5.3.3" + }, + "devDependencies": { + "@scala-js/vite-plugin-scalajs": "^1.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz", + "integrity": "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.56.0.tgz", + "integrity": "sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.56.0.tgz", + "integrity": "sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.56.0.tgz", + "integrity": "sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.56.0.tgz", + "integrity": "sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.56.0.tgz", + "integrity": "sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.56.0.tgz", + "integrity": "sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.56.0.tgz", + "integrity": "sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.56.0.tgz", + "integrity": "sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.56.0.tgz", + "integrity": "sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.56.0.tgz", + "integrity": "sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.56.0.tgz", + "integrity": "sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.56.0.tgz", + "integrity": "sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.56.0.tgz", + "integrity": "sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.56.0.tgz", + "integrity": "sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.56.0.tgz", + "integrity": "sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.56.0.tgz", + "integrity": "sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.56.0.tgz", + "integrity": "sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.56.0.tgz", + "integrity": "sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.56.0.tgz", + "integrity": "sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.56.0.tgz", + "integrity": "sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.56.0.tgz", + "integrity": "sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.56.0.tgz", + "integrity": "sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.56.0.tgz", + "integrity": "sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz", + "integrity": "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@scala-js/vite-plugin-scalajs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@scala-js/vite-plugin-scalajs/-/vite-plugin-scalajs-1.1.0.tgz", + "integrity": "sha512-WEKe9KBRVCCIhr9aomjJ4VRZq9FByXVMcLRwGhaRTfqmIE4foYgdKTO1reyx7n8L+52bfDyHbUay/Y4kwORJEA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "vite": "4.1.4 - 7" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/bootstrap": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", + "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT", + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.56.0.tgz", + "integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.56.0", + "@rollup/rollup-android-arm64": "4.56.0", + "@rollup/rollup-darwin-arm64": "4.56.0", + "@rollup/rollup-darwin-x64": "4.56.0", + "@rollup/rollup-freebsd-arm64": "4.56.0", + "@rollup/rollup-freebsd-x64": "4.56.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.56.0", + "@rollup/rollup-linux-arm-musleabihf": "4.56.0", + "@rollup/rollup-linux-arm64-gnu": "4.56.0", + "@rollup/rollup-linux-arm64-musl": "4.56.0", + "@rollup/rollup-linux-loong64-gnu": "4.56.0", + "@rollup/rollup-linux-loong64-musl": "4.56.0", + "@rollup/rollup-linux-ppc64-gnu": "4.56.0", + "@rollup/rollup-linux-ppc64-musl": "4.56.0", + "@rollup/rollup-linux-riscv64-gnu": "4.56.0", + "@rollup/rollup-linux-riscv64-musl": "4.56.0", + "@rollup/rollup-linux-s390x-gnu": "4.56.0", + "@rollup/rollup-linux-x64-gnu": "4.56.0", + "@rollup/rollup-linux-x64-musl": "4.56.0", + "@rollup/rollup-openbsd-x64": "4.56.0", + "@rollup/rollup-openharmony-arm64": "4.56.0", + "@rollup/rollup-win32-arm64-msvc": "4.56.0", + "@rollup/rollup-win32-ia32-msvc": "4.56.0", + "@rollup/rollup-win32-x64-gnu": "4.56.0", + "@rollup/rollup-win32-x64-msvc": "4.56.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..7f3b437 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,18 @@ +{ + "name": "ssbudget-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@scala-js/vite-plugin-scalajs": "^1.0.0", + "vite": "^6.0.0" + }, + "dependencies": { + "bootstrap": "^5.3.3" + } +} diff --git a/frontend/src/main/scala/ssbudget/frontend/Main.scala b/frontend/src/main/scala/ssbudget/frontend/Main.scala new file mode 100644 index 0000000..023a1d6 --- /dev/null +++ b/frontend/src/main/scala/ssbudget/frontend/Main.scala @@ -0,0 +1,116 @@ +package ssbudget.frontend + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import ssbudget.frontend.auth.AuthState +import ssbudget.frontend.components.{Layout, Loading, LoadingState} +import ssbudget.frontend.pages.{LoginPage, SetupPage} +import ssbudget.frontend.services.{ApiClient, DataService} +import ssbudget.frontend.util.MoneyFormatter + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.util.{Failure, Success} + +object Main { + + private val dataState: Var[LoadingState[Unit]] = Var(LoadingState.Loading) + private val apiClient = new ApiClient() + + def main(args: Array[String]): Unit = { + val container = dom.document.getElementById("app") + + // First check auth state + AuthState.initialize(apiClient) + + render(container, appRoot()) + } + + private def appRoot(): HtmlElement = { + div( + child <-- AuthState.current.signal.map { + case AuthState.Loading => loadingView("Checking authentication...") + case AuthState.NeedsSetup => SetupPage(apiClient) + case AuthState.NeedsLogin(hasKey) => LoginPage(apiClient, hasKey) + case AuthState.LoggedIn => mainAppView() + case AuthState.Error(msg) => authErrorView(msg) + }, + ) + } + + private def mainAppView(): HtmlElement = { + // When logged in, initialize data and show main app + div( + onMountCallback { _ => + if dataState.now() == LoadingState.Loading then { + DataService.instance.initialize().onComplete { + case Success(_) => + // Initialize MoneyFormatter with currency data + MoneyFormatter.init(DataService.instance.primaryCurrency, DataService.instance.exchangeRates) + dataState.set(LoadingState.Loaded(())) + case Failure(ex) => + dom.console.error(s"Failed to initialize: ${ex.getMessage}") + dataState.set(LoadingState.Error(s"Failed to load data: ${ex.getMessage}")) + } + } + }, + child <-- dataState.signal.map { + case LoadingState.Loading => loadingView("Loading data...") + case LoadingState.Loaded(_) => Layout(apiClient) + case LoadingState.Error(msg) => errorView(msg) + }, + ) + } + + private def loadingView(message: String): HtmlElement = { + div( + cls := "d-flex justify-content-center align-items-center vh-100", + div( + cls := "text-center", + div(cls := "spinner-border text-primary mb-3", role := "status"), + div(cls := "text-muted", message), + ), + ) + } + + private def authErrorView(message: String): HtmlElement = { + div( + cls := "d-flex justify-content-center align-items-center vh-100", + div( + cls := "text-center", + div(cls := "text-danger fs-1 mb-3", "!"), + div(cls := "text-danger", message), + button( + cls := "btn btn-primary mt-3", + "Retry", + onClick --> { _ => + AuthState.initialize(apiClient) + }, + ), + ), + ) + } + + private def errorView(message: String): HtmlElement = { + div( + cls := "d-flex justify-content-center align-items-center vh-100", + div( + cls := "text-center", + div(cls := "text-danger fs-1 mb-3", "!"), + div(cls := "text-danger", message), + button( + cls := "btn btn-primary mt-3", + "Retry", + onClick --> { _ => + dataState.set(LoadingState.Loading) + DataService.instance.initialize().onComplete { + case Success(_) => + MoneyFormatter.init(DataService.instance.primaryCurrency, DataService.instance.exchangeRates) + dataState.set(LoadingState.Loaded(())) + case Failure(ex) => dataState.set(LoadingState.Error(s"Failed to load data: ${ex.getMessage}")) + } + }, + ), + ), + ) + } +} diff --git a/frontend/src/main/scala/ssbudget/frontend/Page.scala b/frontend/src/main/scala/ssbudget/frontend/Page.scala new file mode 100644 index 0000000..d7a52a6 --- /dev/null +++ b/frontend/src/main/scala/ssbudget/frontend/Page.scala @@ -0,0 +1,12 @@ +package ssbudget.frontend + +sealed trait Page + +object Page { + case object Dashboard extends Page + case object Budget extends Page + case object Accounts extends Page + case object Periods extends Page + case object Settings extends Page + case object NotFound extends Page +} diff --git a/frontend/src/main/scala/ssbudget/frontend/Router.scala b/frontend/src/main/scala/ssbudget/frontend/Router.scala new file mode 100644 index 0000000..42b5ec1 --- /dev/null +++ b/frontend/src/main/scala/ssbudget/frontend/Router.scala @@ -0,0 +1,54 @@ +package ssbudget.frontend + +import com.raquo.laminar.api.L.* +import com.raquo.waypoint.* +import org.scalajs.dom + +object Router + extends com.raquo.waypoint.Router[Page]( + routes = List( + Route.static(Page.Dashboard, root / endOfSegments), + Route.static(Page.Budget, root / "budget" / endOfSegments), + Route.static(Page.Accounts, root / "accounts" / endOfSegments), + Route.static(Page.Periods, root / "periods" / endOfSegments), + Route.static(Page.Settings, root / "settings" / endOfSegments), + ), + getPageTitle = { + case Page.Dashboard => "SSBudget - Dashboard" + case Page.Budget => "SSBudget - Budget" + case Page.Accounts => "SSBudget - Accounts" + case Page.Periods => "SSBudget - Periods" + case Page.Settings => "SSBudget - Settings" + case Page.NotFound => "SSBudget - Not Found" + }, + serializePage = { + case Page.Dashboard => "/" + case Page.Budget => "/budget" + case Page.Accounts => "/accounts" + case Page.Periods => "/periods" + case Page.Settings => "/settings" + case Page.NotFound => "/404" + }, + deserializePage = { + case "/" => Page.Dashboard + case "/budget" => Page.Budget + case "/accounts" => Page.Accounts + case "/periods" => Page.Periods + case "/settings" => Page.Settings + case _ => Page.NotFound + }, + ) { + + def linkTo(page: Page): Binder[HtmlElement] = { + Binder { el => + val isLinkElement = el.ref.isInstanceOf[dom.html.Anchor] + if isLinkElement then { + el.amend(href := absoluteUrlForPage(page)) + } + (onClick + .filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) + .preventDefault + --> (_ => pushState(page))).bind(el) + } + } +} diff --git a/frontend/src/main/scala/ssbudget/frontend/auth/AuthState.scala b/frontend/src/main/scala/ssbudget/frontend/auth/AuthState.scala new file mode 100644 index 0000000..bdbbac7 --- /dev/null +++ b/frontend/src/main/scala/ssbudget/frontend/auth/AuthState.scala @@ -0,0 +1,64 @@ +package ssbudget.frontend.auth + +import com.raquo.laminar.api.L.* +import ssbudget.frontend.services.ApiClient + +import scala.concurrent.{ExecutionContext, Future} +import scala.util.{Failure, Success} + +sealed trait AuthState +object AuthState { + case object Loading extends AuthState + case object NeedsSetup extends AuthState + case class NeedsLogin(hasPasskeys: Boolean) extends AuthState + case object LoggedIn extends AuthState + case class Error(message: String) extends AuthState + + val current: Var[AuthState] = Var(Loading) + + def initialize(apiClient: ApiClient)(implicit ec: ExecutionContext): Future[Unit] = { + current.set(Loading) + fetchAndUpdateStatus(apiClient) + } + + private def fetchAndUpdateStatus(apiClient: ApiClient)(implicit ec: ExecutionContext): Future[Unit] = { + apiClient.auth.status().transform { + case Success(status) => + if !status.configured then { + current.set(NeedsSetup) + } else if status.loggedIn then { + current.set(LoggedIn) + } else { + current.set(NeedsLogin(status.passkeyCount > 0)) + } + Success(()) + case Failure(ex) => + current.set(Error(s"Failed to check auth status: ${ex.getMessage}")) + Success(()) + } + } + + def logout(apiClient: ApiClient)(implicit ec: ExecutionContext): Future[Unit] = { + apiClient.auth.logout().transform { + case Success(_) => + apiClient.auth.status().onComplete { + case Success(status) => + current.set(NeedsLogin(status.passkeyCount > 0)) + case Failure(_) => + current.set(NeedsLogin(false)) + } + Success(()) + case Failure(ex) => + current.set(Error(s"Logout failed: ${ex.getMessage}")) + Success(()) + } + } + + def setLoggedIn(): Unit = { + current.set(LoggedIn) + } + + def refreshStatus(apiClient: ApiClient)(implicit ec: ExecutionContext): Future[Unit] = { + fetchAndUpdateStatus(apiClient) + } +} diff --git a/frontend/src/main/scala/ssbudget/frontend/components/Layout.scala b/frontend/src/main/scala/ssbudget/frontend/components/Layout.scala new file mode 100644 index 0000000..67f43fa --- /dev/null +++ b/frontend/src/main/scala/ssbudget/frontend/components/Layout.scala @@ -0,0 +1,31 @@ +package ssbudget.frontend.components + +import com.raquo.laminar.api.L.* +import ssbudget.frontend.{Page, Router} +import ssbudget.frontend.pages.* +import ssbudget.frontend.services.ApiClient + +object Layout { + + def apply(apiClient: ApiClient): HtmlElement = { + div( + NavBar(apiClient), + div( + cls := "main-content mx-auto", + styleAttr := "max-width: 1600px", + child <-- Router.currentPageSignal.map(page => renderPage(page, apiClient)), + ), + ) + } + + private def renderPage(page: Page, apiClient: ApiClient): HtmlElement = { + page match { + case Page.Dashboard => DashboardPage() + case Page.Budget => BudgetPage() + case Page.Accounts => AccountsPage() + case Page.Periods => PeriodsPage() + case Page.Settings => SettingsPage(apiClient) + case Page.NotFound => NotFoundPage() + } + } +} diff --git a/frontend/src/main/scala/ssbudget/frontend/components/Loading.scala b/frontend/src/main/scala/ssbudget/frontend/components/Loading.scala new file mode 100644 index 0000000..7cc60f7 --- /dev/null +++ b/frontend/src/main/scala/ssbudget/frontend/components/Loading.scala @@ -0,0 +1,135 @@ +package ssbudget.frontend.components + +import com.raquo.laminar.api.L.* +import org.scalajs.dom + +import scala.concurrent.Future +import scala.concurrent.ExecutionContext.Implicits.global +import scala.util.{Failure, Success} + +object Loading { + + /** Bootstrap spinner (small, inline) */ + def spinner: HtmlElement = + span(cls := "spinner-border spinner-border-sm", role := "status") + + /** Render content based on loading state */ + def render[T](state: Signal[LoadingState[T]])(content: T => HtmlElement): HtmlElement = + div( + child <-- state.map { + case LoadingState.Loading => spinner + case LoadingState.Loaded(data) => content(data) + case LoadingState.Error(msg) => span(cls := "text-danger", msg) + }, + ) + + /** Button that shows spinner while action is in progress */ + def actionButton( + label: String, + action: () => Future[Unit], + btnClass: String = "btn btn-primary btn-sm", + ): HtmlElement = { + val loading = Var(false) + button( + tpe := "button", + cls := btnClass, + disabled <-- loading.signal, + child <-- loading.signal.map { + case true => spinner + case false => span(label) + }, + onClick --> { _ => + loading.set(true) + action().onComplete { result => + loading.set(false) + result match { + case Failure(ex) => + dom.console.error(s"Action failed: ${ex.getMessage}") + case Success(_) => // success, nothing to do + } + } + }, + ) + } + + /** Action group that provides a button and onKeyDown handler sharing the same loading state. Use this when you want Enter key on inputs to trigger + * the same action as clicking the button. + */ + class ActionGroup( + label: String, + action: () => Future[Unit], + btnClass: String = "btn btn-primary btn-sm", + ) { + private val loading = Var(false) + + private def executeAction(): Unit = { + if !loading.now() then { + loading.set(true) + action().onComplete { result => + loading.set(false) + result match { + case Failure(ex) => + dom.console.error(s"Action failed: ${ex.getMessage}") + case Success(_) => // success + } + } + } + } + + /** The button element */ + val btn: HtmlElement = button( + tpe := "button", + cls := btnClass, + disabled <-- loading.signal, + child <-- loading.signal.map { + case true => spinner + case false => span(label) + }, + onClick --> { _ => executeAction() }, + ) + + /** onKeyDown modifier that triggers action on Enter key */ + val onEnter: Modifier[HtmlElement] = onKeyDown --> { ev => + if ev.key == "Enter" then executeAction() + } + } + + /** Create an action group for shared button/Enter key handling */ + def actionGroup( + label: String, + action: () => Future[Unit], + btnClass: String = "btn btn-primary btn-sm", + ): ActionGroup = new ActionGroup(label, action, btnClass) + + /** Button with confirm dialog before action */ + def confirmActionButton( + label: String, + confirmMessage: String, + action: () => Future[Unit], + btnClass: String = "btn btn-danger btn-sm", + ): HtmlElement = { + val loading = Var(false) + button( + tpe := "button", + cls := btnClass, + disabled <-- loading.signal, + child <-- loading.signal.map { + case true => spinner + case false => span(label) + }, + onClick --> { _ => + if dom.window.confirm(confirmMessage) then { + loading.set(true) + action().onComplete { result => + loading.set(false) + result match { + case Failure(ex) => + dom.console.error(s"Action failed: ${ex.getMessage}") + case Success(_) => // success + } + } + } + }, + ) + } +} diff --git a/frontend/src/main/scala/ssbudget/frontend/components/LoadingState.scala b/frontend/src/main/scala/ssbudget/frontend/components/LoadingState.scala new file mode 100644 index 0000000..5658db8 --- /dev/null +++ b/frontend/src/main/scala/ssbudget/frontend/components/LoadingState.scala @@ -0,0 +1,34 @@ +package ssbudget.frontend.components + +enum LoadingState[+T] { + case Loading + case Loaded(data: T) + case Error(message: String) + + def map[U](f: T => U): LoadingState[U] = this match { + case Loading => Loading + case Loaded(data) => Loaded(f(data)) + case Error(msg) => Error(msg) + } + + def flatMap[U](f: T => LoadingState[U]): LoadingState[U] = this match { + case Loading => Loading + case Loaded(data) => f(data) + case Error(msg) => Error(msg) + } + + def getOrElse[U >: T](default: => U): U = this match { + case Loaded(data) => data + case _ => default + } + + def isLoading: Boolean = this == Loading + def isLoaded: Boolean = this match { + case Loaded(_) => true + case _ => false + } + def isError: Boolean = this match { + case Error(_) => true + case _ => false + } +} diff --git a/frontend/src/main/scala/ssbudget/frontend/components/NavBar.scala b/frontend/src/main/scala/ssbudget/frontend/components/NavBar.scala new file mode 100644 index 0000000..f136873 --- /dev/null +++ b/frontend/src/main/scala/ssbudget/frontend/components/NavBar.scala @@ -0,0 +1,78 @@ +package ssbudget.frontend.components + +import com.raquo.laminar.api.L.* +import ssbudget.frontend.{Page, Router} +import ssbudget.frontend.auth.AuthState +import ssbudget.frontend.services.ApiClient + +import scala.concurrent.ExecutionContext.Implicits.global + +object NavBar { + + def apply(apiClient: ApiClient): HtmlElement = { + val isOpen = Var(false) + + navTag( + cls := "navbar navbar-expand-lg navbar-dark bg-dark", + div( + cls := "container-fluid", + a( + cls := "navbar-brand", + href := "/", + Router.linkTo(Page.Dashboard), + "SSBudget", + ), + button( + cls := "navbar-toggler", + tpe := "button", + onClick --> { _ => isOpen.update(!_) }, + span(cls := "navbar-toggler-icon"), + ), + div( + cls <-- isOpen.signal.map { open => + if open then "collapse navbar-collapse show" + else "collapse navbar-collapse" + }, + idAttr := "navbarNav", + ul( + cls := "navbar-nav me-auto", + navItem(Page.Dashboard, "Dashboard", isOpen), + navItem(Page.Budget, "Budget", isOpen), + navItem(Page.Accounts, "Accounts", isOpen), + navItem(Page.Periods, "Periods", isOpen), + ), + ul( + cls := "navbar-nav", + navItem(Page.Settings, "Settings", isOpen), + li( + cls := "nav-item", + button( + cls := "btn btn-outline-light btn-sm ms-2", + "Logout", + onClick --> { _ => + AuthState.logout(apiClient) + }, + ), + ), + ), + ), + ), + ) + } + + private def navItem(page: Page, label: String, isOpen: Var[Boolean]): HtmlElement = { + li( + cls := "nav-item", + a( + cls <-- Router.currentPageSignal.map { currentPage => + if currentPage == page then "nav-link active" + else "nav-link" + }, + href := Router.absoluteUrlForPage(page), + Router.linkTo(page), + onClick --> { _ => isOpen.set(false) }, + label, + ), + ) + } +} diff --git a/frontend/src/main/scala/ssbudget/frontend/pages/AccountsPage.scala b/frontend/src/main/scala/ssbudget/frontend/pages/AccountsPage.scala new file mode 100644 index 0000000..d6a1436 --- /dev/null +++ b/frontend/src/main/scala/ssbudget/frontend/pages/AccountsPage.scala @@ -0,0 +1,393 @@ +package ssbudget.frontend.pages + +import com.raquo.laminar.api.L.* +import ssbudget.frontend.components.Loading +import ssbudget.frontend.services.DataService +import ssbudget.frontend.util.{Formatting, MoneyFormatter} +import ssbudget.shared.model.* + +import scala.concurrent.ExecutionContext.Implicits.global + +object AccountsPage { + + private val dataService = DataService.instance + + // Bank accounts state + private val editingAccountId = Var[Option[AccountId]](None) + private val addingAccount = Var(false) + + // Savings accounts state + private val editingSavingsId = Var[Option[SavingsAccountId]](None) + private val addingSavings = Var(false) + + def apply(): HtmlElement = { + div( + cls := "container-fluid mt-3", + h4("Accounts"), + div( + cls := "row g-3", + div(cls := "col-lg-6", bankAccountsCard()), + div(cls := "col-lg-6", savingsAccountsCard()), + ), + ) + } + + // ============ Bank Accounts ============ + + private def bankAccountsCard(): HtmlElement = { + div( + cls := "card", + div( + cls := "card-header py-2 d-flex justify-content-between align-items-center", + span("Bank Accounts"), + button(cls := "btn btn-sm btn-outline-primary", "+ Add", onClick --> { _ => addingAccount.set(true) }), + ), + div( + cls := "card-body p-0", + table( + cls := "table table-sm table-hover mb-0", + thead( + tr(th("Account"), th("Currency"), th("Balance"), th("Last Updated"), th("Actions")), + ), + tbody( + children <-- dataService.accounts + .combineWith(dataService.balanceSnapshots) + .combineWith(editingAccountId.signal) + .map { case (accounts, snapshots, editingId) => + accounts.map { account => + val snapshot = snapshots.find(_.accountId == account.id) + accountRow(account, snapshot, editingId) + } + }, + child <-- addingAccount.signal + .combineWith(dataService.enabledCurrencies) + .combineWith(dataService.primaryCurrency) + .map { + case (true, currencies, primary) => addAccountRow(currencies, primary) + case (false, _, _) => emptyNode + }, + ), + ), + ), + div( + cls := "card-footer py-2", + div( + cls := "d-flex justify-content-between", + div( + span(cls := "fw-bold", "Total: "), + span(cls := "font-monospace fw-bold text-primary", MoneyFormatter.formatChild(dataService.totalBalance)), + ), + child <-- dataService.exchangeRates.combineWith(dataService.primaryCurrency).map { case (rates, primary) => + if rates.isEmpty then emptyNode + else { + val rateStrings = rates.map { case (currency, rate) => s"${currency.code}→${primary.code}: $rate" }.mkString(", ") + div(cls := "text-muted small", s"Rates: $rateStrings") + } + }, + ), + ), + ) + } + + private def accountRow( + account: Account, + snapshotOpt: Option[BalanceSnapshot], + editingId: Option[AccountId], + ): HtmlElement = { + if editingId.contains(account.id) then editAccountRow(account) + else { + val balanceEl = snapshotOpt.fold[HtmlElement](span("-"))(s => MoneyFormatter.format(s.amount, s.currency)) + val dateStr = snapshotOpt.fold("-")(s => Formatting.formatDate(s.recordedAt)) + + tr( + td(account.name), + td(span(cls := "badge text-bg-secondary", account.currency.code)), + td(cls := "font-monospace", balanceEl), + td(cls := "text-muted small", dateStr), + td(button(cls := "btn btn-outline-secondary btn-sm", "Edit", onClick --> { _ => editingAccountId.set(Some(account.id)) })), + ) + } + } + + private def editAccountRow(account: Account): HtmlElement = { + var nameRef: org.scalajs.dom.html.Input = null + val currencyValue = Var(account.currency) + + tr( + cls := "table-warning", + td( + input( + cls := "form-control form-control-sm", + tpe := "text", + defaultValue := account.name, + onMountCallback(ctx => nameRef = ctx.thisNode.ref), + onMountFocus, + ), + ), + td( + child <-- dataService.enabledCurrencies.map { currencies => + select( + cls := "form-select form-select-sm", + currencies.map { curr => + option(value := curr.code, selected := (curr == account.currency), curr.code) + }, + onChange.mapToValue --> { v => currencyValue.set(Currency(v)) }, + ) + }, + ), + td(colSpan := 3, cls := "text-muted small", "Balance is edited from Dashboard"), + td( + div( + cls := "btn-group btn-group-sm", + button( + tpe := "button", + cls := "btn btn-primary btn-sm", + "Save", + onClick --> { _ => + editingAccountId.set(None) + }, + ), + button(tpe := "button", cls := "btn btn-secondary btn-sm", "Cancel", onClick --> { _ => editingAccountId.set(None) }), + Loading.actionButton( + "Del", + () => dataService.deleteAccount(account.id).map(_ => editingAccountId.set(None)), + "btn btn-danger btn-sm", + ), + ), + ), + ) + } + + private def addAccountRow(currencies: List[Currency], primaryCurrency: Currency): HtmlElement = { + val currencyValue = Var(primaryCurrency) + var nameRef: org.scalajs.dom.html.Input = null + + val addAction = Loading.actionGroup( + "Add", + () => { + val name = Option(nameRef).map(_.value.trim).getOrElse("") + if name.nonEmpty then { + dataService.addAccount(name, currencyValue.now()).map(_ => addingAccount.set(false)) + } else { + scala.concurrent.Future.successful(()) + } + }, + "btn btn-success btn-sm", + ) + + tr( + cls := "table-primary", + td( + input( + cls := "form-control form-control-sm", + tpe := "text", + placeholder := "Account name", + onMountCallback(ctx => nameRef = ctx.thisNode.ref), + onMountFocus, + addAction.onEnter, + ), + ), + td( + select( + cls := "form-select form-select-sm", + currencies.map(curr => option(value := curr.code, selected := (curr == primaryCurrency), curr.code)), + onChange.mapToValue --> { v => currencyValue.set(Currency(v)) }, + ), + ), + td(colSpan := 3, cls := "text-muted small", "Initial balance: 0"), + td( + div( + cls := "btn-group btn-group-sm", + addAction.btn, + button(tpe := "button", cls := "btn btn-secondary btn-sm", "Cancel", onClick --> { _ => addingAccount.set(false) }), + ), + ), + ) + } + + // ============ Savings Accounts ============ + + private def savingsAccountsCard(): HtmlElement = { + div( + cls := "card", + div( + cls := "card-header py-2 d-flex justify-content-between align-items-center", + span("Savings Accounts"), + button(cls := "btn btn-sm btn-outline-success", "+ Add", onClick --> { _ => addingSavings.set(true) }), + ), + div( + cls := "card-body p-0", + table( + cls := "table table-sm table-hover mb-0", + thead( + tr(th("Account"), th("Currency"), th("Balance"), th("Target/mo"), th("Actions")), + ), + tbody( + children <-- dataService.savingsAccounts + .combineWith(editingSavingsId.signal) + .map { case (accounts, editingId) => + accounts.map(account => savingsRow(account, editingId)) + }, + child <-- addingSavings.signal + .combineWith(dataService.enabledCurrencies) + .combineWith(dataService.primaryCurrency) + .map { + case (true, currencies, primary) => addSavingsRow(currencies, primary) + case (false, _, _) => emptyNode + }, + ), + ), + ), + div( + cls := "card-footer py-2 text-muted small", + "Savings transactions are managed from the Budget page", + ), + ) + } + + private def savingsRow(account: SavingsAccount, editingId: Option[SavingsAccountId]): HtmlElement = { + if editingId.contains(account.id) then editSavingsRow(account) + else { + val balanceEl = MoneyFormatter.format(account.currentBalance, account.currency) + val targetEl = account.plannedMonthly.fold[HtmlElement](span("-"))(t => MoneyFormatter.format(t, account.currency)) + + tr( + td(account.name), + td(span(cls := "badge text-bg-success", account.currency.code)), + td(cls := "font-monospace", balanceEl), + td(cls := "font-monospace text-muted", targetEl), + td(button(cls := "btn btn-outline-secondary btn-sm", "Edit", onClick --> { _ => editingSavingsId.set(Some(account.id)) })), + ) + } + } + + private def editSavingsRow(account: SavingsAccount): HtmlElement = { + var nameRef: org.scalajs.dom.html.Input = null + var targetRef: org.scalajs.dom.html.Input = null + val currencyValue = Var(account.currency) + + val saveAction = Loading.actionGroup( + "Save", + () => { + val name = Option(nameRef).map(_.value.trim).getOrElse("") + val targetTxt = Option(targetRef).map(_.value.trim).getOrElse("") + if name.nonEmpty then { + val targetCents = if targetTxt.isEmpty then None else Some((targetTxt.toDoubleOption.getOrElse(0.0) * 100).toLong) + dataService.updateSavingsAccount(account.id, name, currencyValue.now(), targetCents).map(_ => editingSavingsId.set(None)) + } else { + scala.concurrent.Future.successful(()) + } + }, + "btn btn-primary btn-sm", + ) + + tr( + cls := "table-warning", + td( + input( + cls := "form-control form-control-sm", + tpe := "text", + defaultValue := account.name, + onMountCallback(ctx => nameRef = ctx.thisNode.ref), + onMountFocus, + saveAction.onEnter, + ), + ), + td( + child <-- dataService.enabledCurrencies.map { currencies => + select( + cls := "form-select form-select-sm", + currencies.map { curr => + option(value := curr.code, selected := (curr == account.currency), curr.code) + }, + onChange.mapToValue --> { v => currencyValue.set(Currency(v)) }, + ) + }, + ), + td(cls := "text-muted small", "Balance: Dashboard"), + td( + input( + cls := "form-control form-control-sm text-end", + tpe := "number", + stepAttr := "0.01", + placeholder := "No target", + defaultValue := account.plannedMonthly.fold("")(t => (t / 100.0).toString), + onMountCallback(ctx => targetRef = ctx.thisNode.ref), + saveAction.onEnter, + ), + ), + td( + div( + cls := "btn-group btn-group-sm", + saveAction.btn, + button(tpe := "button", cls := "btn btn-secondary btn-sm", "Cancel", onClick --> { _ => editingSavingsId.set(None) }), + Loading.actionButton( + "Del", + () => dataService.deleteSavingsAccount(account.id).map(_ => editingSavingsId.set(None)), + "btn btn-danger btn-sm", + ), + ), + ), + ) + } + + private def addSavingsRow(currencies: List[Currency], primaryCurrency: Currency): HtmlElement = { + var nameRef: org.scalajs.dom.html.Input = null + var targetRef: org.scalajs.dom.html.Input = null + val currencyValue = Var(primaryCurrency) + + val addAction = Loading.actionGroup( + "Add", + () => { + val name = Option(nameRef).map(_.value.trim).getOrElse("") + val targetTxt = Option(targetRef).map(_.value.trim).getOrElse("") + if name.nonEmpty then { + val targetCents = if targetTxt.isEmpty then None else Some((targetTxt.toDoubleOption.getOrElse(0.0) * 100).toLong) + dataService.addSavingsAccount(name, currencyValue.now(), targetCents).map(_ => addingSavings.set(false)) + } else { + scala.concurrent.Future.successful(()) + } + }, + "btn btn-success btn-sm", + ) + + tr( + cls := "table-success", + td( + input( + cls := "form-control form-control-sm", + tpe := "text", + placeholder := "Account name", + onMountCallback(ctx => nameRef = ctx.thisNode.ref), + onMountFocus, + addAction.onEnter, + ), + ), + td( + select( + cls := "form-select form-select-sm", + currencies.map(curr => option(value := curr.code, selected := (curr == primaryCurrency), curr.code)), + onChange.mapToValue --> { v => currencyValue.set(Currency(v)) }, + ), + ), + td(cls := "text-muted small", "Balance: 0"), + td( + input( + cls := "form-control form-control-sm", + tpe := "number", + stepAttr := "0.01", + placeholder := "Target/mo", + onMountCallback(ctx => targetRef = ctx.thisNode.ref), + addAction.onEnter, + ), + ), + td( + div( + cls := "btn-group btn-group-sm", + addAction.btn, + button(tpe := "button", cls := "btn btn-secondary btn-sm", "Cancel", onClick --> { _ => addingSavings.set(false) }), + ), + ), + ) + } +} diff --git a/frontend/src/main/scala/ssbudget/frontend/pages/BudgetPage.scala b/frontend/src/main/scala/ssbudget/frontend/pages/BudgetPage.scala new file mode 100644 index 0000000..183b584 --- /dev/null +++ b/frontend/src/main/scala/ssbudget/frontend/pages/BudgetPage.scala @@ -0,0 +1,527 @@ +package ssbudget.frontend.pages + +import com.raquo.laminar.api.L.* +import ssbudget.frontend.components.Loading +import ssbudget.frontend.services.DataService +import ssbudget.frontend.util.MoneyFormatter +import ssbudget.shared.model.* + +import scala.concurrent.Future +import scala.concurrent.ExecutionContext.Implicits.global + +object BudgetPage { + + private val dataService = DataService.instance + + private val editingItemId = Var[Option[ExpenseDefId]](None) + private val payingItemId = Var[Option[ExpenseDefId]](None) + private val addingPlanned = Var(false) + private val addingEstimated = Var(false) + private val addingIncome = Var(false) + private val showOnlyPending = Var(false) + + // Savings state + private val savingToAccountId = Var[Option[SavingsAccountId]](None) + private val expandedSavingsIds = Var[Set[SavingsAccountId]](Set.empty) + + def apply(): HtmlElement = { + div( + cls := "container-fluid mt-3", + h4("Budget"), + div( + cls := "row g-3 mb-3", + div(cls := "col-lg-6", plannedItemsCard()), + div(cls := "col-lg-6", estimatedExpensesCard()), + ), + div(cls := "row g-3", div(cls := "col-12", plannedSavingsCard())), + ) + } + + private def plannedItemsCard(): HtmlElement = { + div( + cls := "card", + div( + cls := "card-header py-2 d-flex justify-content-between align-items-center", + span("Planned Items"), + div( + cls := "d-flex align-items-center gap-2", + div( + cls := "form-check form-switch mb-0", + input( + cls := "form-check-input", + tpe := "checkbox", + idAttr := "showPendingOnly", + checked <-- showOnlyPending.signal, + onChange.mapToChecked --> showOnlyPending.writer, + ), + label(cls := "form-check-label small", forId := "showPendingOnly", "Pending only"), + ), + div( + cls := "btn-group btn-group-sm", + button(cls := "btn btn-outline-primary", "+ Expense", onClick --> { _ => addingPlanned.set(true) }), + button(cls := "btn btn-outline-success", "+ Income", onClick --> { _ => addingIncome.set(true) }), + ), + ), + ), + div( + cls := "card-body p-0", + table( + cls := "table table-sm table-hover mb-0", + thead( + tr(th("Name"), th(cls := "text-end", "Expected"), th(cls := "text-end", "Actual"), th(cls := "text-center", "Status"), th("Actions")), + ), + tbody( + children <-- dataService.plannedExpenses + .combineWith(dataService.currentPeriodRecords) + .combineWith(payingItemId.signal) + .combineWith(editingItemId.signal) + .combineWith(showOnlyPending.signal) + .map { case (items, records, payingId, editingId, pendingOnly) => + val filteredItems = + if pendingOnly then items.filter(item => !records.exists(r => r.expenseDefId == item.id && r.paidAmount.isDefined)) + else items + filteredItems.map(item => plannedItemRow(item, records, payingId, editingId, isIncome = false)) + }, + child <-- addingPlanned.signal.combineWith(dataService.primaryCurrency).map { + case (true, currency) => addItemRow(BudgetItemType.PlannedExpense, addingPlanned, columns = 5, currency) + case (false, _) => emptyNode + }, + tr(cls := "table-secondary", td(colSpan := 5, cls := "py-1 small text-muted", "— Incomes —")), + children <-- dataService.plannedIncomes + .combineWith(dataService.currentPeriodRecords) + .combineWith(payingItemId.signal) + .combineWith(editingItemId.signal) + .combineWith(showOnlyPending.signal) + .map { case (items, records, payingId, editingId, pendingOnly) => + val filteredItems = + if pendingOnly then items.filter(item => !records.exists(r => r.expenseDefId == item.id && r.paidAmount.isDefined)) + else items + filteredItems.map(item => plannedItemRow(item, records, payingId, editingId, isIncome = true)) + }, + child <-- addingIncome.signal.combineWith(dataService.primaryCurrency).map { + case (true, currency) => addItemRow(BudgetItemType.PlannedIncome, addingIncome, columns = 5, currency) + case (false, _) => emptyNode + }, + ), + ), + ), + div( + cls := "card-footer py-2", + div( + cls := "d-flex justify-content-between mb-1", + span(cls := "text-muted small", "Unpaid Expenses"), + span(cls := "font-monospace small", MoneyFormatter.formatChild(dataService.unpaidPlannedExpenses)), + ), + div( + cls := "d-flex justify-content-between", + span(cls := "text-muted small", "Pending Income"), + span(cls := "font-monospace small", MoneyFormatter.formatChild(dataService.pendingIncome)), + ), + ), + ) + } + + private def estimatedExpensesCard(): HtmlElement = { + div( + cls := "card", + div( + cls := "card-header py-2 d-flex justify-content-between align-items-center", + span("Estimated Expenses"), + button(cls := "btn btn-sm btn-outline-primary", "+ Add", onClick --> { _ => addingEstimated.set(true) }), + ), + div( + cls := "card-body p-0", + table( + cls := "table table-sm table-hover mb-0", + thead(tr(th("Name"), th(cls := "text-end", "Monthly Est."), th(cls := "text-end", "Scaled"), th("Actions"))), + tbody( + children <-- dataService.estimatedExpenses + .combineWith(dataService.daysRemainingInPeriod) + .combineWith(editingItemId.signal) + .map { case (items, daysRemaining, editingId) => + val scaleFactor = daysRemaining.toDouble / 30.0 + items.map(item => estimatedItemRow(item, scaleFactor, editingId)) + }, + child <-- addingEstimated.signal.combineWith(dataService.primaryCurrency).map { + case (true, currency) => addItemRow(BudgetItemType.EstimatedExpense, addingEstimated, columns = 4, currency) + case (false, _) => emptyNode + }, + ), + ), + ), + div( + cls := "card-footer py-2 d-flex justify-content-between", + span("Scaled Total"), + span(cls := "font-monospace", MoneyFormatter.formatChild(dataService.scaledEstimatedExpenses)), + ), + ) + } + + private def plannedSavingsCard(): HtmlElement = { + div( + cls := "card", + div( + cls := "card-header py-2", + span("Planned Savings"), + ), + div( + cls := "card-body p-0", + table( + cls := "table table-sm table-hover mb-0", + thead( + tr( + th("Account"), + th(cls := "text-end", "Target"), + th(cls := "text-end", "Saved"), + th(cls := "text-end", "Remaining"), + th(), + ), + ), + tbody( + children <-- dataService.savingsAccounts + .combineWith(dataService.currentPeriodSavingsTransactions) + .combineWith(savingToAccountId.signal) + .combineWith(expandedSavingsIds.signal) + .map { case (accounts, txns, savingToId, expandedIds) => + // Show accounts with targets first, then accounts without targets + val (withTargets, withoutTargets) = accounts.partition(_.plannedMonthly.isDefined) + val sortedAccounts = withTargets ++ withoutTargets + sortedAccounts.flatMap { account => + val periodTxns = txns.filter(_.accountId == account.id) + val periodTotal = periodTxns.map(_.amount).sum + val isExpanded = expandedIds.contains(account.id) + val mainRow = savingsTargetRow(account, periodTotal, periodTxns, savingToId, isExpanded) + val suggestedAmount = account.plannedMonthly.map(_ - periodTotal).getOrElse(0L) + val txnRows = if isExpanded then { + periodTxns.map(txn => savingsTransactionRow(txn, account.currency)) :+ + (if savingToId.contains(account.id) then addSavingsTransactionRow(account, suggestedAmount) + else addTransactionButton(account)) + } else Nil + mainRow :: txnRows + } + }, + ), + ), + ), + div( + cls := "card-footer py-2 d-flex justify-content-between", + span("Remaining to Save"), + span(cls := "font-monospace text-warning", MoneyFormatter.formatChild(dataService.remainingSavingsTarget)), + ), + ) + } + + private def savingsTargetRow( + account: SavingsAccount, + periodContribution: Long, + periodTxns: List[SavingsTransaction], + savingToId: Option[SavingsAccountId], + isExpanded: Boolean, + ): HtmlElement = { + val currency = account.currency + val savedEl = MoneyFormatter.format(periodContribution, currency) + + val (targetEl, remainingEl, progressClass) = account.plannedMonthly match { + case Some(target) => + val remaining = math.max(0L, target - periodContribution) + val cls = if periodContribution >= target then "text-success" else "text-warning" + (MoneyFormatter.format(target, currency), MoneyFormatter.format(remaining, currency), cls) + case None => + (span("-"), span("-"), "text-muted") + } + + tr( + styleAttr := "cursor: pointer", + onClick --> { _ => + if isExpanded then { + expandedSavingsIds.update(_ - account.id) + savingToAccountId.update(id => if id.contains(account.id) then None else id) + } else expandedSavingsIds.update(_ + account.id) + }, + td( + span(cls := "me-1", if isExpanded then "▼" else "▶"), + account.name, + span(cls := "ms-2 badge text-bg-success", currency.code), + ), + td(cls := "text-end font-monospace", targetEl), + td(cls := s"text-end font-monospace $progressClass", savedEl), + td(cls := s"text-end font-monospace $progressClass", remainingEl), + td(), + ) + } + + private def savingsTransactionRow(txn: SavingsTransaction, currency: Currency): HtmlElement = { + import ssbudget.frontend.util.Formatting + val sign = if txn.amount >= 0 then "+" else "" + val colorCls = if txn.amount >= 0 then "text-success" else "text-danger" + val dateStr = Formatting.formatDate(txn.createdAt) + + tr( + cls := "table-light", + td(cls := "ps-4 text-muted small", dateStr), + td(colSpan := 2, cls := "small", txn.note.getOrElse[String]("-")), + td(cls := s"text-end font-monospace small $colorCls", span(sign), MoneyFormatter.format(math.abs(txn.amount), currency)), + td( + Loading.actionButton( + "×", + () => dataService.deleteSavingsTransaction(txn.id), + "btn btn-outline-danger btn-sm py-0", + ), + ), + ) + } + + private def addTransactionButton(account: SavingsAccount): HtmlElement = { + tr( + cls := "table-light", + td(colSpan := 4, cls := "ps-4"), + td( + button( + cls := "btn btn-outline-primary btn-sm py-0", + "+ Add", + onClick --> { _ => savingToAccountId.set(Some(account.id)) }, + ), + ), + ) + } + + private def addSavingsTransactionRow(account: SavingsAccount, suggestedAmount: Long): HtmlElement = { + var amountRef: org.scalajs.dom.html.Input = null + var noteRef: org.scalajs.dom.html.Input = null + + val addAction = Loading.actionGroup( + "Add", + () => { + val amountTxt = Option(amountRef).map(_.value.trim).getOrElse("") + val note = Option(noteRef).map(_.value.trim).filter(_.nonEmpty) + amountTxt.toDoubleOption match { + case Some(amount) => + val amountCents = (amount * 100).toLong + if amountCents != 0 then { + dataService.addSavingsTransaction(account.id, amountCents, note).map(_ => savingToAccountId.set(None)) + } else Future.successful(()) + case None => Future.successful(()) + } + }, + "btn btn-success btn-sm py-0", + ) + + tr( + cls := "table-info", + td(cls := "ps-4 text-muted small", "New"), + td( + colSpan := 2, + input( + cls := "form-control form-control-sm", + tpe := "text", + placeholder := "Note (optional)", + onMountCallback(ctx => noteRef = ctx.thisNode.ref), + addAction.onEnter, + ), + ), + td( + input( + cls := "form-control form-control-sm text-end", + tpe := "number", + stepAttr := "0.01", + placeholder := "Amount", + defaultValue := (math.max(0L, suggestedAmount) / 100.0).toString, + onMountCallback(ctx => amountRef = ctx.thisNode.ref), + onMountFocus, + addAction.onEnter, + ), + ), + td( + div( + cls := "btn-group btn-group-sm", + addAction.btn, + button(tpe := "button", cls := "btn btn-secondary btn-sm py-0", "×", onClick --> { _ => savingToAccountId.set(None) }), + ), + ), + ) + } + + private def plannedItemRow( + item: BudgetItemDefinition, + records: List[ExpenseRecord], + payingId: Option[ExpenseDefId], + editingId: Option[ExpenseDefId], + isIncome: Boolean, + ): HtmlElement = { + val record = records.find(_.expenseDefId == item.id) + val paidAmount = record.flatMap(_.paidAmount) + val isPaid = paidAmount.isDefined + + if payingId.contains(item.id) then payItemRow(item) + else if editingId.contains(item.id) then editItemRow(item, columns = 5) + else { + val statusLabel = if isPaid then (if isIncome then "Received" else "Paid") else "Pending" + val actionLabel = if isIncome then "Receive" else "Pay" + val undoLabel = if isIncome then "Undo" else "Unpay" + val statusBadge = if isPaid then "text-bg-success" else "text-bg-secondary" + + tr( + td(item.name), + td(cls := "text-end font-monospace", item.fixedEstimate.fold[HtmlElement](span("-"))(MoneyFormatter.formatPrimary)), + td(cls := "text-end font-monospace", paidAmount.fold[HtmlElement](span("-"))(MoneyFormatter.formatPrimary)), + td(cls := "text-center", span(cls := s"badge $statusBadge", statusLabel)), + td( + div( + cls := "btn-group btn-group-sm", + if isPaid then List( + button(cls := "btn btn-outline-secondary btn-sm", "Edit", onClick --> { _ => editingItemId.set(Some(item.id)) }), + Loading.actionButton(undoLabel, () => dataService.unmarkBudgetItemAsPaid(item.id), "btn btn-outline-warning btn-sm"), + ) + else + List( + button(cls := "btn btn-outline-success btn-sm", actionLabel, onClick --> { _ => payingItemId.set(Some(item.id)) }), + button(cls := "btn btn-outline-secondary btn-sm", "Edit", onClick --> { _ => editingItemId.set(Some(item.id)) }), + ), + ), + ), + ) + } + } + + private def payItemRow(item: BudgetItemDefinition): HtmlElement = { + var inputRef: org.scalajs.dom.html.Input = null + + tr( + cls := "table-info", + td(item.name), + td(cls := "text-end font-monospace", item.fixedEstimate.fold[HtmlElement](span("-"))(MoneyFormatter.formatPrimary)), + td(moneyInput(item.fixedEstimate, ref => inputRef = ref, autoFocus = true)), + td(), + td( + saveCancel( + onSave = () => { + dataService.markBudgetItemAsPaid(item.id, parseCents(inputRef)).map(_ => payingItemId.set(None)) + }, + onCancel = () => payingItemId.set(None), + ), + ), + ) + } + + private def estimatedItemRow(item: BudgetItemDefinition, scaleFactor: Double, editingId: Option[ExpenseDefId]): HtmlElement = { + val monthlyEstimate = item.fixedEstimate.getOrElse(0L) + val scaledEstimate = (monthlyEstimate * scaleFactor).toLong + + if editingId.contains(item.id) then editItemRow(item, columns = 4) + else + tr( + td(item.name), + td(cls := "text-end font-monospace", MoneyFormatter.formatPrimary(monthlyEstimate)), + td(cls := "text-end font-monospace", MoneyFormatter.formatPrimary(scaledEstimate)), + td(button(cls := "btn btn-outline-secondary btn-sm", "Edit", onClick --> { _ => editingItemId.set(Some(item.id)) })), + ) + } + + private def editItemRow(item: BudgetItemDefinition, columns: Int): HtmlElement = { + var nameRef: org.scalajs.dom.html.Input = null + var estimateRef: org.scalajs.dom.html.Input = null + val emptyCols = columns - 3 + + tr( + cls := "table-warning", + td(textInput(item.name, ref => nameRef = ref)), + td(moneyInput(item.fixedEstimate, ref => estimateRef = ref, autoFocus = true)), + (0 until emptyCols).map(_ => td()), + td( + saveCancelDelete( + onSave = () => { + dataService.updateBudgetItemEstimate(item.id, parseCents(estimateRef), item.currency).map(_ => editingItemId.set(None)) + }, + onCancel = () => editingItemId.set(None), + onDelete = () => { + dataService.deleteBudgetItem(item.id).map(_ => editingItemId.set(None)) + }, + ), + ), + ) + } + + private def addItemRow(itemType: BudgetItemType, addingVar: Var[Boolean], columns: Int, currency: Currency): HtmlElement = { + var nameRef: org.scalajs.dom.html.Input = null + var estimateRef: org.scalajs.dom.html.Input = null + val emptyCols = columns - 3 + val isIncome = itemType == BudgetItemType.PlannedIncome + val namePlaceholder = if isIncome then "Income name" else "Expense name" + val amountPlaceholder = if isIncome then "Expected" else "Estimate" + + tr( + cls := "table-primary", + td(textInput("", ref => nameRef = ref, placeholderText = namePlaceholder, autoFocus = true)), + td(moneyInput(None, ref => estimateRef = ref, placeholderText = amountPlaceholder)), + (0 until emptyCols).map(_ => td()), + td( + saveCancel( + onSave = () => { + val name = Option(nameRef).map(_.value.trim).getOrElse("") + if name.nonEmpty then { + dataService.addBudgetItem(name, itemType, parseCents(estimateRef), currency).map(_ => addingVar.set(false)) + } else { + Future.successful(()) + } + }, + onCancel = () => addingVar.set(false), + saveLabel = "Add", + ), + ), + ) + } + + private def textInput( + defaultVal: String, + refCallback: org.scalajs.dom.html.Input => Unit, + placeholderText: String = "", + autoFocus: Boolean = false, + ): HtmlElement = { + input( + cls := "form-control form-control-sm", + tpe := "text", + defaultValue := defaultVal, + Option.when(placeholderText.nonEmpty)(placeholder := placeholderText), + onMountCallback(ctx => refCallback(ctx.thisNode.ref.asInstanceOf[org.scalajs.dom.html.Input])), + Option.when(autoFocus)(onMountFocus), + ) + } + + private def moneyInput( + defaultCents: Option[Long], + refCallback: org.scalajs.dom.html.Input => Unit, + placeholderText: String = "Amount", + autoFocus: Boolean = false, + ): HtmlElement = { + input( + cls := "form-control form-control-sm text-end", + tpe := "number", + stepAttr := "0.01", + placeholder := placeholderText, + defaultValue := defaultCents.map(c => (c / 100.0).toString).getOrElse(""), + onMountCallback(ctx => refCallback(ctx.thisNode.ref.asInstanceOf[org.scalajs.dom.html.Input])), + Option.when(autoFocus)(onMountFocus), + ) + } + + private def parseCents(input: org.scalajs.dom.html.Input): Long = { + Option(input).flatMap(_.value.toDoubleOption).map(d => (d * 100).toLong).getOrElse(0L) + } + + private def saveCancel(onSave: () => Future[Unit], onCancel: () => Unit, saveLabel: String = "Save"): HtmlElement = { + div( + cls := "btn-group btn-group-sm", + Loading.actionButton(saveLabel, onSave, "btn btn-success btn-sm"), + button(tpe := "button", cls := "btn btn-secondary btn-sm", "Cancel", onClick --> { _ => onCancel() }), + ) + } + + private def saveCancelDelete(onSave: () => Future[Unit], onCancel: () => Unit, onDelete: () => Future[Unit]): HtmlElement = { + div( + cls := "btn-group btn-group-sm", + Loading.actionButton("Save", onSave, "btn btn-primary btn-sm"), + button(tpe := "button", cls := "btn btn-secondary btn-sm", "Cancel", onClick --> { _ => onCancel() }), + Loading.actionButton("Del", onDelete, "btn btn-danger btn-sm"), + ) + } +} diff --git a/frontend/src/main/scala/ssbudget/frontend/pages/DashboardPage.scala b/frontend/src/main/scala/ssbudget/frontend/pages/DashboardPage.scala new file mode 100644 index 0000000..0100c43 --- /dev/null +++ b/frontend/src/main/scala/ssbudget/frontend/pages/DashboardPage.scala @@ -0,0 +1,309 @@ +package ssbudget.frontend.pages + +import com.raquo.laminar.api.L.* +import org.scalajs.dom +import ssbudget.frontend.components.Loading +import ssbudget.frontend.services.DataService +import ssbudget.frontend.util.{Formatting, MoneyFormatter} +import ssbudget.shared.model.* + +import java.time.format.DateTimeFormatter +import java.time.{Instant, ZoneOffset} +import scala.concurrent.Future +import scala.concurrent.ExecutionContext.Implicits.global + +object DashboardPage { + + private val dataService = DataService.instance + + private val isEditingBalances = Var(false) + private val editedBalances = Var(Map.empty[AccountId, Long]) + private val editedSavingsBalances = Var(Map.empty[SavingsAccountId, Long]) + private val copyButtonText = Var("Copy Summary") + + def apply(): HtmlElement = { + div( + cls := "container-fluid mt-3", + div( + cls := "d-flex justify-content-between align-items-center mb-3", + h4(cls := "mb-0", "Dashboard"), + button( + cls := "btn btn-sm btn-outline-secondary", + child.text <-- copyButtonText.signal, + onClick --> { _ => copySummaryToClipboard() }, + ), + ), + div( + cls := "row g-3", + div( + cls := "col-lg-5", + summaryPanel(), + periodCard(), + ), + div(cls := "col-lg-7", accountsQuickView()), + ), + ) + } + + private def summaryPanel(): HtmlElement = { + div( + cls := "card mb-3", + div( + cls := "card-body py-2", + // Quick summary row + div( + cls := "row align-items-center mb-2", + div( + cls := "col-auto", + div(cls := "text-muted small", "BALANCE"), + div(cls := "fs-4 fw-bold font-monospace", MoneyFormatter.formatChild(dataService.bankAccountBalance)), + ), + div(cls := "col-auto fs-4 text-muted", "→"), + div( + cls := "col-auto", + div(cls := "text-muted small", "FREE"), + div(cls := "fs-5 font-monospace text-success fw-bold", MoneyFormatter.formatChild(dataService.freeMoney)), + ), + div(cls := "col-auto fs-4 text-muted", "÷"), + div( + cls := "col-auto", + div( + cls := "text-muted small", + child.text <-- dataService.daysRemainingInPeriod.map(d => s"$d DAYS"), + ), + div(cls := "fs-5 font-monospace text-primary fw-bold", MoneyFormatter.formatChild(dataService.dailyBudget)), + ), + ), + // Accounting breakdown + hr(cls := "my-2"), + div( + cls := "font-monospace small", + accountingRow("Balance", dataService.bankAccountBalance, positive = true, bold = true), + accountingRow("+ Pending Income", dataService.pendingIncome, positive = true), + accountingRow("- Planned Expenses", dataService.unpaidPlannedExpenses, positive = false), + accountingRow("- Estimated Expenses", dataService.scaledEstimatedExpenses, positive = false), + accountingRow("- Remaining Savings", dataService.remainingSavingsTarget, positive = false), + hr(cls := "my-1"), + accountingRow("= Free Money", dataService.freeMoney, positive = true, bold = true), + ), + ), + ) + } + + private def accountingRow(label: String, amount: Signal[Money], positive: Boolean, bold: Boolean = false): HtmlElement = { + val textCls = if positive then "text-success" else "text-danger" + val fontCls = if bold then "fw-bold" else "" + div( + cls := s"d-flex justify-content-between $fontCls", + span(label), + span(cls := textCls, MoneyFormatter.formatChild(amount)), + ) + } + + private def periodCard(): HtmlElement = { + div( + cls := "card", + div(cls := "card-header py-2", "Current Period"), + div( + cls := "card-body py-2", + child <-- dataService.currentPeriod.map { + case Some(period) => + div( + div( + cls := "d-flex justify-content-between mb-2", + span(s"Started: ${Formatting.formatDate(period.startDate)}"), + span(cls := "text-muted", child.text <-- dataService.daysRemainingInPeriod.map(d => s"$d days remaining")), + ), + div( + cls := "progress", + styleAttr := "height: 8px", + div( + cls := "progress-bar", + role := "progressbar", + styleAttr <-- dataService.daysRemainingInPeriod.map { _ => + s"width: ${Formatting.periodProgress(period.startDate)}%" + }, + ), + ), + ) + case None => div(cls := "text-muted", "No active period") + }, + ), + ) + } + + private def accountsQuickView(): HtmlElement = { + div( + cls := "card", + div( + cls := "card-header py-2 d-flex justify-content-between align-items-center", + span("Accounts"), + child <-- isEditingBalances.signal.map { isEditing => + if isEditing then div( + cls := "btn-group btn-group-sm", + Loading.actionButton( + "Save All", + () => saveAllBalances(), + "btn btn-success btn-sm py-0", + ), + button( + cls := "btn btn-secondary btn-sm py-0", + "Cancel", + onClick --> { _ => + isEditingBalances.set(false) + editedBalances.set(Map.empty) + editedSavingsBalances.set(Map.empty) + }, + ), + ) + else + button( + cls := "btn btn-sm btn-outline-primary py-0", + "Edit Balances", + onClick --> { _ => startEditingBalances() }, + ) + }, + ), + div( + cls := "card-body p-0", + table( + cls := "table table-sm table-hover mb-0", + thead(tr(th("Account"), th(cls := "text-end", "Balance"))), + tbody( + // Bank accounts + children <-- dataService.accounts + .combineWith(dataService.balanceSnapshots) + .combineWith(isEditingBalances.signal) + .map { case (accounts, snapshots, isEditing) => + accounts.map(account => bankAccountQuickRow(account, snapshots.find(_.accountId == account.id), isEditing)) + }, + // Separator + tr(cls := "table-secondary", td(colSpan := 2, cls := "py-1 small text-muted", "— Savings —")), + // Savings accounts + children <-- dataService.savingsAccounts + .combineWith(isEditingBalances.signal) + .map { case (accounts, isEditing) => + accounts.map(account => savingsAccountQuickRow(account, isEditing)) + }, + ), + ), + ), + div( + cls := "card-footer py-2 d-flex justify-content-between", + span(cls := "fw-bold", "Total"), + span(cls := "font-monospace fw-bold", MoneyFormatter.formatChild(dataService.totalBalance)), + ), + ) + } + + private def bankAccountQuickRow(account: Account, balanceOpt: Option[BalanceSnapshot], isEditing: Boolean): HtmlElement = { + val currentAmount = balanceOpt.map(_.amount).getOrElse(0L) + + if isEditing then tr( + cls := "table-info", + td(account.name), + td( + div( + cls := "input-group input-group-sm", + input( + cls := "form-control form-control-sm text-end", + tpe := "number", + stepAttr := "0.01", + defaultValue := (currentAmount / 100.0).toString, + onInput.mapToValue --> { v => v.toDoubleOption.foreach(d => editedBalances.update(_.updated(account.id, (d * 100).toLong))) }, + ), + span(cls := "input-group-text py-0", account.currency.code), + ), + ), + ) + else + tr( + td(account.name), + td(cls := "text-end font-monospace", balanceOpt.fold[HtmlElement](span("-"))(b => MoneyFormatter.format(b.amount, b.currency))), + ) + } + + private def savingsAccountQuickRow(account: SavingsAccount, isEditing: Boolean): HtmlElement = { + if isEditing then tr( + cls := "table-info", + td(account.name), + td( + div( + cls := "input-group input-group-sm", + input( + cls := "form-control form-control-sm text-end", + tpe := "number", + stepAttr := "0.01", + defaultValue := (account.currentBalance / 100.0).toString, + onInput.mapToValue --> { v => v.toDoubleOption.foreach(d => editedSavingsBalances.update(_.updated(account.id, (d * 100).toLong))) }, + ), + span(cls := "input-group-text py-0", account.currency.code), + ), + ), + ) + else tr(td(account.name), td(cls := "text-end font-monospace", MoneyFormatter.format(account.currentBalance, account.currency))) + } + + private def startEditingBalances(): Unit = { + import com.raquo.airstream.ownership.OneTimeOwner + given owner: OneTimeOwner = new OneTimeOwner(() => ()) + + val accounts = dataService.accounts.observe.now() + val snapshots = dataService.balanceSnapshots.observe.now() + val savingsAccounts = dataService.savingsAccounts.observe.now() + + val initialBankBalances = accounts.map(acc => acc.id -> snapshots.find(_.accountId == acc.id).map(_.amount).getOrElse(0L)).toMap + val initialSavingsBalances = savingsAccounts.map(acc => acc.id -> acc.currentBalance).toMap + + editedBalances.set(initialBankBalances) + editedSavingsBalances.set(initialSavingsBalances) + isEditingBalances.set(true) + } + + private def saveAllBalances(): Future[Unit] = { + import com.raquo.airstream.ownership.OneTimeOwner + given owner: OneTimeOwner = new OneTimeOwner(() => ()) + + val accounts = dataService.accounts.observe.now() + val edited = editedBalances.now() + val bankFutures = accounts.flatMap(acc => edited.get(acc.id).map(amount => dataService.updateAccountBalance(acc.id, amount))) + + val savingsAccounts = dataService.savingsAccounts.observe.now() + val editedSavings = editedSavingsBalances.now() + val savingsFutures = + savingsAccounts.flatMap(acc => editedSavings.get(acc.id).map(amount => dataService.updateSavingsAccountBalance(acc.id, amount))) + + Future.sequence(bankFutures ++ savingsFutures).map { _ => + isEditingBalances.set(false) + editedBalances.set(Map.empty) + editedSavingsBalances.set(Map.empty) + } + } + + private def copySummaryToClipboard(): Unit = { + import com.raquo.airstream.ownership.OneTimeOwner + given owner: OneTimeOwner = new OneTimeOwner(() => ()) + + val balance = dataService.bankAccountBalance.observe.now() + val availableNow = dataService.availableNow.observe.now() + val freeMoney = dataService.freeMoney.observe.now() + val dailyBudget = dataService.dailyBudget.observe.now() + val daysRemaining = dataService.daysRemainingInPeriod.observe.now() + + val dateStr = DateTimeFormatter.ofPattern("MMM d").format(Instant.now().atZone(ZoneOffset.UTC)) + val summary = + s"""Budget Update ($dateStr) + |Balance: ${balance.formatted} + |Available: ${availableNow.formatted} + |Free: ${freeMoney.formatted} + |Daily: ${dailyBudget.formatted} ($daysRemaining days left)""".stripMargin + + dom.window.navigator.clipboard + .writeText(summary) + .toFuture + .foreach { _ => + copyButtonText.set("Copied!") + dom.window.setTimeout(() => copyButtonText.set("Copy Summary"), 2000) + }(scala.concurrent.ExecutionContext.global) + } +} diff --git a/frontend/src/main/scala/ssbudget/frontend/pages/LoginPage.scala b/frontend/src/main/scala/ssbudget/frontend/pages/LoginPage.scala new file mode 100644 index 0000000..8fa7550 --- /dev/null +++ b/frontend/src/main/scala/ssbudget/frontend/pages/LoginPage.scala @@ -0,0 +1,129 @@ +package ssbudget.frontend.pages + +import com.raquo.laminar.api.L.* +import ssbudget.frontend.auth.AuthState +import ssbudget.frontend.services.ApiClient +import ssbudget.frontend.util.WebAuthnFacade + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.util.{Failure, Success} + +object LoginPage { + + def apply(apiClient: ApiClient, hasPasskeys: Boolean): HtmlElement = { + val passwordVar = Var("") + val errorVar = Var(Option.empty[String]) + val isSubmittingVar = Var(false) + val passkeyLoadingVar = Var(false) + + def loginWithPasskey(): Unit = { + passkeyLoadingVar.set(true) + errorVar.set(None) + + apiClient.auth.loginPasskeyStart().onComplete { + case Success(options) => + WebAuthnFacade.getCredential(options).onComplete { + case Success(response) => + apiClient.auth.loginPasskeyFinish(response).onComplete { + case Success(_) => + passkeyLoadingVar.set(false) + AuthState.setLoggedIn() + case Failure(ex) => + passkeyLoadingVar.set(false) + errorVar.set(Some(s"Passkey authentication failed: ${ex.getMessage}")) + } + case Failure(ex) => + passkeyLoadingVar.set(false) + errorVar.set(Some(s"WebAuthn failed: ${ex.getMessage}")) + } + case Failure(ex) => + passkeyLoadingVar.set(false) + errorVar.set(Some(s"Failed to start authentication: ${ex.getMessage}")) + } + } + + div( + cls := "container", + div( + cls := "row justify-content-center align-items-center min-vh-100", + div( + cls := "col-md-6 col-lg-4", + div( + cls := "card shadow", + div( + cls := "card-body p-4", + h3(cls := "card-title text-center mb-4", "SSBudget"), + p(cls := "text-muted text-center mb-4", "Sign in to continue"), + child.maybe <-- errorVar.signal.map(_.map { error => + div(cls := "alert alert-danger", error) + }), + form( + onSubmit.preventDefault --> { _ => + val password = passwordVar.now() + + if password.isEmpty then { + errorVar.set(Some("Password is required")) + } else { + isSubmittingVar.set(true) + errorVar.set(None) + + apiClient.auth.login(password).onComplete { + case Success(_) => + AuthState.setLoggedIn() + case Failure(ex) => + isSubmittingVar.set(false) + errorVar.set(Some("Invalid password")) + } + } + }, + div( + cls := "mb-3", + label(cls := "form-label", forId := "password", "Password"), + input( + cls := "form-control", + idAttr := "password", + tpe := "password", + placeholder := "Enter password", + controlled( + value <-- passwordVar.signal, + onInput.mapToValue --> passwordVar.writer, + ), + autoFocus := true, + ), + ), + button( + cls := "btn btn-primary w-100", + tpe := "submit", + disabled <-- isSubmittingVar.signal.combineWith(passwordVar.signal).map { case (submitting, pass) => + submitting || pass.isEmpty + }, + child.text <-- isSubmittingVar.signal.map { + case true => "Signing in..." + case false => "Sign In" + }, + ), + ), + if hasPasskeys && WebAuthnFacade.isSupported then { + div( + cls := "mt-3", + hr(), + button( + cls := "btn btn-outline-secondary w-100", + disabled <-- passkeyLoadingVar.signal, + onClick --> { _ => loginWithPasskey() }, + child.text <-- passkeyLoadingVar.signal.map { + case true => "Authenticating..." + case false => "Sign in with Passkey" + }, + ), + ) + } else { + emptyNode + }, + ), + ), + ), + ), + ) + } +} diff --git a/frontend/src/main/scala/ssbudget/frontend/pages/NotFoundPage.scala b/frontend/src/main/scala/ssbudget/frontend/pages/NotFoundPage.scala new file mode 100644 index 0000000..2c62b8f --- /dev/null +++ b/frontend/src/main/scala/ssbudget/frontend/pages/NotFoundPage.scala @@ -0,0 +1,24 @@ +package ssbudget.frontend.pages + +import com.raquo.laminar.api.L.* +import ssbudget.frontend.{Page, Router} + +object NotFoundPage { + + def apply(): HtmlElement = { + div( + cls := "container mt-5", + div( + cls := "text-center", + h1(cls := "display-1", "404"), + p(cls := "lead", "Page not found"), + a( + cls := "btn btn-primary", + href := "/", + Router.linkTo(Page.Dashboard), + "Go to Dashboard", + ), + ), + ) + } +} diff --git a/frontend/src/main/scala/ssbudget/frontend/pages/PeriodsPage.scala b/frontend/src/main/scala/ssbudget/frontend/pages/PeriodsPage.scala new file mode 100644 index 0000000..fe1df23 --- /dev/null +++ b/frontend/src/main/scala/ssbudget/frontend/pages/PeriodsPage.scala @@ -0,0 +1,169 @@ +package ssbudget.frontend.pages + +import com.raquo.laminar.api.L.* +import ssbudget.frontend.components.Loading +import ssbudget.frontend.services.DataService +import ssbudget.frontend.util.Formatting +import ssbudget.shared.model.Period + +import java.time.{LocalDate, ZoneId} +import scala.concurrent.ExecutionContext.Implicits.global + +object PeriodsPage { + + private val dataService = DataService.instance + + def apply(): HtmlElement = { + div( + cls := "container-fluid mt-3", + h4("Periods"), + div( + cls := "row g-3", + div( + cls := "col-lg-6", + currentPeriodCard(), + ), + div( + cls := "col-lg-6", + periodHistoryCard(), + ), + ), + ) + } + + private def currentPeriodCard(): HtmlElement = { + div( + cls := "card", + div( + cls := "card-header py-2", + "Current Period", + ), + div( + cls := "card-body", + child <-- dataService.currentPeriod.map { + case Some(period) => + div( + div( + cls := "row mb-3", + div( + cls := "col-4", + div(cls := "text-muted small", "Started"), + div(cls := "fw-bold", Formatting.formatDate(period.startDate)), + ), + div( + cls := "col-4", + div(cls := "text-muted small", "Expected End"), + div(cls := "fw-bold", expectedEndDate()), + ), + div( + cls := "col-4", + div(cls := "text-muted small", "Days Left"), + div( + cls := "fw-bold", + child.text <-- dataService.daysRemainingInPeriod.map(_.toString), + ), + ), + ), + div( + cls := "mb-3", + div(cls := "text-muted small mb-1", "Progress"), + div( + cls := "progress", + styleAttr := "height: 20px", + div( + cls := "progress-bar", + role := "progressbar", + styleAttr <-- dataService.daysRemainingInPeriod.map { _ => + val progress = Formatting.periodProgress(period.startDate) + s"width: $progress%" + }, + child.text <-- dataService.daysRemainingInPeriod.map { _ => + val progress = Formatting.periodProgress(period.startDate) + s"$progress%" + }, + ), + ), + ), + div( + cls := "d-grid", + Loading.actionButton( + "End Period & Start New", + () => dataService.startNewPeriod(), + "btn btn-warning", + ), + ), + ) + case None => + div( + cls := "text-center py-4", + p(cls := "text-muted", "No active period"), + Loading.actionButton( + "Start New Period", + () => dataService.startNewPeriod(), + "btn btn-primary", + ), + ) + }, + ), + ) + } + + private def periodHistoryCard(): HtmlElement = { + div( + cls := "card", + div( + cls := "card-header py-2", + "Period History", + ), + div( + cls := "card-body p-0", + table( + cls := "table table-sm table-hover mb-0", + thead( + tr( + th("Start"), + th("End"), + th("Duration"), + th("Status"), + ), + ), + tbody( + children <-- dataService.periods.map { periods => + periods.sortBy(_.startDate).reverse.map(periodRow) + }, + ), + ), + ), + ) + } + + private def expectedEndDate(): String = { + val today = LocalDate.now(ZoneId.of("UTC")) + val day25 = today.withDayOfMonth(25) + val periodEnd = if today.getDayOfMonth < 25 then day25 else day25.plusMonths(1) + Formatting.formatLocalDate(periodEnd) + } + + private def periodRow(period: Period): HtmlElement = { + val isActive = period.endDate.isEmpty + val duration = period.endDate match { + case Some(end) => + val days = java.time.temporal.ChronoUnit.DAYS.between(period.startDate, end).toInt + s"$days days" + case None => + val days = Formatting.daysElapsed(period.startDate) + s"$days days (ongoing)" + } + + tr( + cls := (if isActive then "table-active" else ""), + td(Formatting.formatDate(period.startDate)), + td(period.endDate.fold("-")(Formatting.formatDate)), + td(duration), + td( + if isActive then span(cls := "badge text-bg-success", "Active") + else span(cls := "badge text-bg-secondary", "Closed"), + ), + ) + } +} diff --git a/frontend/src/main/scala/ssbudget/frontend/pages/SettingsPage.scala b/frontend/src/main/scala/ssbudget/frontend/pages/SettingsPage.scala new file mode 100644 index 0000000..1e4eae7 --- /dev/null +++ b/frontend/src/main/scala/ssbudget/frontend/pages/SettingsPage.scala @@ -0,0 +1,526 @@ +package ssbudget.frontend.pages + +import com.raquo.laminar.api.L.* +import org.scalajs.dom +import org.scalajs.dom.{File, FileReader, HTMLInputElement, XMLHttpRequest} +import ssbudget.frontend.auth.AuthState +import ssbudget.frontend.components.Loading +import ssbudget.frontend.services.{ApiClient, DataService} +import ssbudget.frontend.util.WebAuthnFacade +import ssbudget.shared.api.PasskeyInfo +import ssbudget.shared.model.{Currency, CurrencySetting} + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.util.{Failure, Success} + +object SettingsPage { + + // Custom attribute for datalist linking + private val listAttr: HtmlAttr[String] = htmlAttr("list", com.raquo.laminar.codecs.StringAsIsCodec) + + private val dataService = DataService.instance + + def apply(apiClient: ApiClient): HtmlElement = { + val passkeysVar = Var(List.empty[PasskeyInfo]) + val loadingVar = Var(true) + val errorVar = Var(Option.empty[String]) + val successVar = Var(Option.empty[String]) + val addingPasskeyVar = Var(false) + val passkeyNameVar = Var("") + val addCurrencyCodeVar = Var("") + val refreshingRatesVar = Var(false) + + def loadPasskeys(): Unit = { + loadingVar.set(true) + apiClient.auth.listPasskeys().onComplete { + case Success(keys) => + passkeysVar.set(keys) + loadingVar.set(false) + case Failure(ex) => + errorVar.set(Some(s"Failed to load passkeys: ${ex.getMessage}")) + loadingVar.set(false) + } + } + + def addPasskey(): Unit = { + val name = passkeyNameVar.now() + addingPasskeyVar.set(true) + errorVar.set(None) + successVar.set(None) + + apiClient.auth.registerPasskeyStart(if name.nonEmpty then Some(name) else None).onComplete { + case Success(options) => + WebAuthnFacade.createCredential(options).onComplete { + case Success(response) => + apiClient.auth.registerPasskeyFinish(response).onComplete { + case Success(_) => + addingPasskeyVar.set(false) + passkeyNameVar.set("") + successVar.set(Some("Passkey added successfully")) + loadPasskeys() + case Failure(ex) => + addingPasskeyVar.set(false) + errorVar.set(Some(s"Failed to register passkey: ${ex.getMessage}")) + } + case Failure(ex) => + addingPasskeyVar.set(false) + errorVar.set(Some(s"WebAuthn failed: ${ex.getMessage}")) + } + case Failure(ex) => + addingPasskeyVar.set(false) + errorVar.set(Some(s"Failed to start registration: ${ex.getMessage}")) + } + } + + def deletePasskey(credentialId: String): Unit = { + errorVar.set(None) + successVar.set(None) + apiClient.auth.deletePasskey(credentialId).onComplete { + case Success(_) => + successVar.set(Some("Passkey deleted")) + loadPasskeys() + case Failure(ex) => + errorVar.set(Some(s"Failed to delete passkey: ${ex.getMessage}")) + } + } + + div( + cls := "container py-4", + onMountCallback { _ => loadPasskeys() }, + h2(cls := "mb-4", "Settings"), + + // Messages + child.maybe <-- errorVar.signal.map(_.map { error => + div( + cls := "alert alert-danger alert-dismissible", + error, + button( + tpe := "button", + cls := "btn-close", + onClick --> { _ => + errorVar.set(None) + }, + ), + ) + }), + child.maybe <-- successVar.signal.map(_.map { msg => + div( + cls := "alert alert-success alert-dismissible", + msg, + button( + tpe := "button", + cls := "btn-close", + onClick --> { _ => + successVar.set(None) + }, + ), + ) + }), + + // Passkeys section + div( + cls := "card mb-4", + div( + cls := "card-header d-flex justify-content-between align-items-center", + h5(cls := "mb-0", "Passkeys"), + child <-- addingPasskeyVar.signal.map { adding => + if adding then { + span(cls := "spinner-border spinner-border-sm") + } else { + emptyNode + } + }, + ), + div( + cls := "card-body", + child <-- loadingVar.signal.map { loading => + if loading then { + div(cls := "text-center py-3", div(cls := "spinner-border text-primary")) + } else { + div( + child <-- passkeysVar.signal.map { keys => + if keys.isEmpty then { + p(cls := "text-muted", "No passkeys registered. Add one for passwordless login.") + } else { + ul( + cls := "list-group list-group-flush mb-3", + keys.map { key => + li( + cls := "list-group-item d-flex justify-content-between align-items-center", + div( + strong(key.displayName.getOrElse("Unnamed passkey")), + br(), + small(cls := "text-muted", s"Added: ${key.createdAt.toString.take(10)}"), + key.lastUsedAt + .map { used => + small(cls := "text-muted ms-2", s"Last used: ${used.toString.take(10)}") + } + .getOrElse(emptyNode), + ), + button( + cls := "btn btn-outline-danger btn-sm", + "Delete", + onClick --> { _ => deletePasskey(key.credentialId) }, + ), + ) + }, + ) + } + }, + + // Add passkey form + if WebAuthnFacade.isSupported then { + div( + cls := "mt-3", + div( + cls := "input-group", + input( + cls := "form-control", + tpe := "text", + placeholder := "Passkey name (optional)", + controlled( + value <-- passkeyNameVar.signal, + onInput.mapToValue --> passkeyNameVar.writer, + ), + ), + button( + cls := "btn btn-primary", + disabled <-- addingPasskeyVar.signal, + "Add Passkey", + onClick --> { _ => addPasskey() }, + ), + ), + ) + } else { + div( + cls := "alert alert-warning mt-3", + "WebAuthn is not supported in this browser. Passkeys are not available.", + ) + }, + ) + } + }, + ), + ), + + // Currencies section + currenciesCard(errorVar, successVar, addCurrencyCodeVar, refreshingRatesVar), + + // Data section (import/export) + dataCard(errorVar, successVar), + + // Account section + div( + cls := "card", + div(cls := "card-header", h5(cls := "mb-0", "Account")), + div( + cls := "card-body", + button( + cls := "btn btn-outline-danger", + "Logout", + onClick --> { _ => + AuthState.logout(apiClient) + }, + ), + ), + ), + ) + } + + private def currenciesCard( + errorVar: Var[Option[String]], + successVar: Var[Option[String]], + addCurrencyCodeVar: Var[String], + refreshingRatesVar: Var[Boolean], + ): HtmlElement = { + + def refreshRates(): Unit = { + refreshingRatesVar.set(true) + errorVar.set(None) + dataService.refreshExchangeRates().onComplete { + case Success(_) => + refreshingRatesVar.set(false) + successVar.set(Some("Exchange rates refreshed")) + case Failure(ex) => + refreshingRatesVar.set(false) + errorVar.set(Some(s"Failed to refresh rates: ${ex.getMessage}")) + } + } + + def enableCurrency(): Unit = { + val code = addCurrencyCodeVar.now().toUpperCase.trim + if code.nonEmpty then { + errorVar.set(None) + dataService.enableCurrency(code).onComplete { + case Success(_) => + addCurrencyCodeVar.set("") + successVar.set(Some(s"Currency $code enabled")) + case Failure(ex) => + errorVar.set(Some(s"Failed to enable currency: ${ex.getMessage}")) + } + } + } + + def disableCurrency(code: String): Unit = { + errorVar.set(None) + dataService.disableCurrency(code).onComplete { + case Success(_) => + successVar.set(Some(s"Currency $code disabled")) + case Failure(ex) => + errorVar.set(Some(s"Failed to disable currency: ${ex.getMessage}")) + } + } + + def setPrimary(code: String): Unit = { + errorVar.set(None) + dataService.setPrimaryCurrency(code).onComplete { + case Success(_) => + successVar.set(Some(s"$code set as primary currency")) + case Failure(ex) => + errorVar.set(Some(s"Failed to set primary currency: ${ex.getMessage}")) + } + } + + div( + cls := "card mb-4", + div( + cls := "card-header d-flex justify-content-between align-items-center", + h5(cls := "mb-0", "Currencies"), + div( + child <-- refreshingRatesVar.signal.map { refreshing => + if refreshing then { + span(cls := "spinner-border spinner-border-sm me-2") + } else { + emptyNode + } + }, + button( + cls := "btn btn-outline-primary btn-sm", + disabled <-- refreshingRatesVar.signal, + "Refresh Rates", + onClick --> { _ => refreshRates() }, + ), + ), + ), + div( + cls := "card-body p-0", + table( + cls := "table table-sm table-hover mb-0", + thead( + tr( + th("Code"), + th("Name"), + th(cls := "text-end", "Rate"), + th("Actions"), + ), + ), + tbody( + children <-- dataService.currencySettings + .combineWith(dataService.exchangeRates) + .map { case (settings, rates) => + settings.map { setting => + val rateStr = + if setting.isPrimary then "-" + else rates.get(setting.code).map(r => f"$r%.4f").getOrElse("N/A") + currencyRow(setting, rateStr, setPrimary, disableCurrency) + } + }, + ), + ), + // Add currency form with searchable dropdown + div( + cls := "p-3 border-top", + child <-- dataService.availableCurrencies + .combineWith(dataService.currencySettings) + .map { case (available, enabled) => + val enabledCodes = enabled.map(_.code.code).toSet + val notYetEnabled = available.filterNot { case (code, _) => enabledCodes.contains(code) } + div( + div( + cls := "input-group", + input( + cls := "form-control", + tpe := "text", + listAttr := "available-currencies", + placeholder := "Search currency (e.g., USD, GBP)", + controlled( + value <-- addCurrencyCodeVar.signal, + onInput.mapToValue --> addCurrencyCodeVar.writer, + ), + onKeyPress --> { e => + if e.key == "Enter" then enableCurrency() + }, + ), + button( + cls := "btn btn-outline-success", + "Add Currency", + onClick --> { _ => enableCurrency() }, + ), + ), + dataList( + idAttr := "available-currencies", + notYetEnabled.map { case (code, name) => + option(value := code, s"$code - $name") + }, + ), + small( + cls := "text-muted mt-1 d-block", + s"${notYetEnabled.size} currencies available", + ), + ) + }, + ), + ), + ) + } + + private def dataCard( + errorVar: Var[Option[String]], + successVar: Var[Option[String]], + ): HtmlElement = { + val importingVar = Var(false) + val fileInputRef = Var(Option.empty[HTMLInputElement]) + + def triggerFileInput(): Unit = { + fileInputRef.now().foreach(_.click()) + } + + def handleFileSelect(input: HTMLInputElement): Unit = { + val files = input.files + if files.length > 0 then { + val file = files(0) + uploadFile(file) + // Reset the input so the same file can be selected again + input.value = "" + } + } + + def uploadFile(file: File): Unit = { + importingVar.set(true) + errorVar.set(None) + successVar.set(None) + + // Read file as ArrayBuffer and send as raw bytes + val reader = new FileReader() + reader.onload = { _ => + val arrayBuffer = reader.result.asInstanceOf[scala.scalajs.js.typedarray.ArrayBuffer] + + val xhr = new XMLHttpRequest() + xhr.open("POST", "/api/database/import", true) + xhr.withCredentials = true + xhr.setRequestHeader("Content-Type", "application/octet-stream") + + xhr.onload = { _ => + importingVar.set(false) + if xhr.status == 200 then { + successVar.set(Some(xhr.responseText)) + } else { + errorVar.set(Some(s"Import failed: ${xhr.responseText}")) + } + } + + xhr.onerror = { _ => + importingVar.set(false) + errorVar.set(Some("Import failed: Network error")) + } + + xhr.send(arrayBuffer) + } + reader.onerror = { _ => + importingVar.set(false) + errorVar.set(Some("Failed to read file")) + } + reader.readAsArrayBuffer(file) + } + + div( + cls := "card mb-4", + div(cls := "card-header", h5(cls := "mb-0", "Data")), + div( + cls := "card-body", + p(cls := "text-muted small", "Export or import your database for backup and restore purposes."), + div( + cls := "d-flex gap-2", + a( + cls := "btn btn-outline-primary", + href := "/api/database/export", + download := "ssbudget_backup.db", + i(cls := "bi bi-download me-2"), + "Export Database", + ), + button( + cls := "btn btn-outline-warning", + disabled <-- importingVar.signal, + onClick --> { _ => triggerFileInput() }, + child <-- importingVar.signal.map { importing => + if importing then { + span( + span(cls := "spinner-border spinner-border-sm me-2"), + "Importing...", + ) + } else { + span( + i(cls := "bi bi-upload me-2"), + "Import Database", + ) + } + }, + ), + input( + tpe := "file", + accept := ".db,.sqlite,.sqlite3", + cls := "d-none", + onMountCallback { ctx => + fileInputRef.set(Some(ctx.thisNode.ref)) + }, + onChange --> { e => + val input = e.target.asInstanceOf[HTMLInputElement] + handleFileSelect(input) + }, + ), + ), + div( + cls := "alert alert-warning mt-3 mb-0 small", + strong("Warning: "), + "Importing a database will replace all your current data. The application will need to be refreshed after import. A backup of the current database will be saved automatically.", + ), + ), + ) + } + + private def currencyRow( + setting: CurrencySetting, + rateStr: String, + setPrimary: String => Unit, + disableCurrency: String => Unit, + ): HtmlElement = { + val code = setting.code.code + tr( + td( + span(cls := "badge text-bg-secondary me-1", code), + if setting.isPrimary then span(cls := "badge text-bg-primary", "Primary") else emptyNode, + ), + td(setting.name), + td(cls := "text-end font-monospace", rateStr), + td( + if setting.isPrimary then { + emptyNode + } else { + div( + cls := "btn-group btn-group-sm", + button( + cls := "btn btn-outline-primary btn-sm", + "Set Primary", + onClick --> { _ => setPrimary(code) }, + ), + button( + cls := "btn btn-outline-danger btn-sm", + "Remove", + onClick --> { _ => disableCurrency(code) }, + ), + ) + }, + ), + ) + } +} diff --git a/frontend/src/main/scala/ssbudget/frontend/pages/SetupPage.scala b/frontend/src/main/scala/ssbudget/frontend/pages/SetupPage.scala new file mode 100644 index 0000000..35fbef6 --- /dev/null +++ b/frontend/src/main/scala/ssbudget/frontend/pages/SetupPage.scala @@ -0,0 +1,110 @@ +package ssbudget.frontend.pages + +import com.raquo.laminar.api.L.* +import ssbudget.frontend.auth.AuthState +import ssbudget.frontend.services.ApiClient + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.util.{Failure, Success} + +object SetupPage { + + def apply(apiClient: ApiClient): HtmlElement = { + val passwordVar = Var("") + val confirmVar = Var("") + val errorVar = Var(Option.empty[String]) + val isSubmittingVar = Var(false) + + val isValidSignal = passwordVar.signal + .combineWith(confirmVar.signal) + .combineWith(isSubmittingVar.signal) + .map { case (password, confirm, submitting) => + password.nonEmpty && password == confirm && !submitting + } + + div( + cls := "container", + div( + cls := "row justify-content-center align-items-center min-vh-100", + div( + cls := "col-md-6 col-lg-4", + div( + cls := "card shadow", + div( + cls := "card-body p-4", + h3(cls := "card-title text-center mb-4", "SSBudget Setup"), + p( + cls := "text-muted text-center mb-4", + "Create a password to secure your budget app.", + ), + child.maybe <-- errorVar.signal.map(_.map { error => + div(cls := "alert alert-danger", error) + }), + form( + onSubmit.preventDefault --> { _ => + val password = passwordVar.now() + val confirm = confirmVar.now() + + if password.isEmpty then { + errorVar.set(Some("Password is required")) + } else if password != confirm then { + errorVar.set(Some("Passwords do not match")) + } else { + isSubmittingVar.set(true) + errorVar.set(None) + + apiClient.auth.setup(password).onComplete { + case Success(_) => + // Setup now returns a session cookie, so we're automatically logged in + AuthState.setLoggedIn() + case Failure(ex) => + isSubmittingVar.set(false) + errorVar.set(Some(ex.getMessage)) + } + } + }, + div( + cls := "mb-3", + label(cls := "form-label", forId := "password", "Password"), + input( + cls := "form-control", + idAttr := "password", + tpe := "password", + placeholder := "Enter password", + controlled( + value <-- passwordVar.signal, + onInput.mapToValue --> passwordVar.writer, + ), + ), + ), + div( + cls := "mb-3", + label(cls := "form-label", forId := "confirm", "Confirm Password"), + input( + cls := "form-control", + idAttr := "confirm", + tpe := "password", + placeholder := "Confirm password", + controlled( + value <-- confirmVar.signal, + onInput.mapToValue --> confirmVar.writer, + ), + ), + ), + button( + cls := "btn btn-primary w-100", + tpe := "submit", + disabled <-- isValidSignal.map(!_), + child.text <-- isSubmittingVar.signal.map { + case true => "Setting up..." + case false => "Create Password" + }, + ), + ), + ), + ), + ), + ), + ) + } +} diff --git a/frontend/src/main/scala/ssbudget/frontend/services/ApiClient.scala b/frontend/src/main/scala/ssbudget/frontend/services/ApiClient.scala new file mode 100644 index 0000000..ece88c8 --- /dev/null +++ b/frontend/src/main/scala/ssbudget/frontend/services/ApiClient.scala @@ -0,0 +1,240 @@ +package ssbudget.frontend.services + +import org.scalajs.dom +import sttp.client3.* +import sttp.tapir.DecodeResult +import sttp.tapir.client.sttp.SttpClientInterpreter +import ssbudget.shared.api.* +import ssbudget.shared.model.* + +import scala.concurrent.{ExecutionContext, Future} + +class ApiClient(implicit ec: ExecutionContext) { + + // Enable credentials to include cookies in requests + private val backend = FetchBackend() + + private val baseUri = uri"${dom.window.location.origin}" + + private val interpreter = SttpClientInterpreter() + + object auth { + def status(): Future[AuthStatus] = { + val request = interpreter.toRequest(AuthEndpoints.client.status, Some(baseUri)) + backend.send(request(())).map(handleResponse) + } + + def setup(password: String): Future[Unit] = { + val request = interpreter.toRequest(AuthEndpoints.client.setup, Some(baseUri)) + backend.send(request(SetupRequest(password))).map(handleResponse) + } + + def login(password: String): Future[Unit] = { + val request = interpreter.toRequest(AuthEndpoints.client.login, Some(baseUri)) + backend.send(request(LoginRequest(password))).map(handleResponse) + } + + def logout(): Future[Unit] = { + val request = interpreter.toRequest(AuthEndpoints.client.logout, Some(baseUri)) + backend.send(request(())).map(handleResponse) + } + + def listPasskeys(): Future[List[PasskeyInfo]] = { + val request = interpreter.toRequest(AuthEndpoints.client.listPasskeys, Some(baseUri)) + backend.send(request(())).map(handleResponse) + } + + def deletePasskey(credentialId: String): Future[Unit] = { + val request = interpreter.toRequest(AuthEndpoints.client.deletePasskey, Some(baseUri)) + backend.send(request(credentialId)).map(handleResponse) + } + + def registerPasskeyStart(displayName: Option[String]): Future[PasskeyRegistrationOptions] = { + val request = interpreter.toRequest(AuthEndpoints.client.registerPasskeyStart, Some(baseUri)) + backend.send(request(PasskeyRegisterStartRequest(displayName))).map(handleResponse) + } + + def registerPasskeyFinish(response: PasskeyRegistrationResponse): Future[Unit] = { + val request = interpreter.toRequest(AuthEndpoints.client.registerPasskeyFinish, Some(baseUri)) + backend.send(request(response)).map(handleResponse) + } + + def loginPasskeyStart(): Future[PasskeyAuthenticationOptions] = { + val request = interpreter.toRequest(AuthEndpoints.client.loginPasskeyStart, Some(baseUri)) + backend.send(request(())).map(handleResponse) + } + + def loginPasskeyFinish(response: PasskeyAuthenticationResponse): Future[Unit] = { + val request = interpreter.toRequest(AuthEndpoints.client.loginPasskeyFinish, Some(baseUri)) + backend.send(request(response)).map(handleResponse) + } + } + + object accounts { + def list(): Future[List[Account]] = { + val request = interpreter.toRequest(Endpoints.client.accounts.list, Some(baseUri)) + backend.send(request(())).map(handleResponse) + } + + def create(dto: CreateAccount): Future[AccountResponse] = { + val request = interpreter.toRequest(Endpoints.client.accounts.create, Some(baseUri)) + backend.send(request(dto)).map(handleResponse) + } + + def delete(id: AccountId): Future[Unit] = { + val request = interpreter.toRequest(Endpoints.client.accounts.delete, Some(baseUri)) + backend.send(request(id)).map(handleResponse) + } + } + + object balances { + def listLatest(): Future[List[BalanceSnapshot]] = { + val request = interpreter.toRequest(Endpoints.client.balances.listLatest, Some(baseUri)) + backend.send(request(())).map(handleResponse) + } + + def create(dto: CreateBalanceSnapshot): Future[BalanceSnapshot] = { + val request = interpreter.toRequest(Endpoints.client.balances.create, Some(baseUri)) + backend.send(request(dto)).map(handleResponse) + } + } + + object budgetItems { + def list(): Future[List[BudgetItemDefinition]] = { + val request = interpreter.toRequest(Endpoints.client.budgetItems.list, Some(baseUri)) + backend.send(request(())).map(handleResponse) + } + + def create(dto: CreateBudgetItem): Future[BudgetItemDefinition] = { + val request = interpreter.toRequest(Endpoints.client.budgetItems.create, Some(baseUri)) + backend.send(request(dto)).map(handleResponse) + } + + def update(id: ExpenseDefId, dto: UpdateBudgetItem): Future[BudgetItemDefinition] = { + val request = interpreter.toRequest(Endpoints.client.budgetItems.update, Some(baseUri)) + backend.send(request((id, dto))).map(handleResponse) + } + + def delete(id: ExpenseDefId): Future[Unit] = { + val request = interpreter.toRequest(Endpoints.client.budgetItems.delete, Some(baseUri)) + backend.send(request(id)).map(handleResponse) + } + } + + object expenseRecords { + def listCurrent(): Future[List[ExpenseRecord]] = { + val request = interpreter.toRequest(Endpoints.client.expenseRecords.listCurrent, Some(baseUri)) + backend.send(request(())).map(handleResponse) + } + + def pay(expenseDefId: ExpenseDefId, dto: PayBudgetItem): Future[ExpenseRecord] = { + val request = interpreter.toRequest(Endpoints.client.expenseRecords.pay, Some(baseUri)) + backend.send(request((expenseDefId, dto))).map(handleResponse) + } + + def unpay(expenseDefId: ExpenseDefId): Future[ExpenseRecord] = { + val request = interpreter.toRequest(Endpoints.client.expenseRecords.unpay, Some(baseUri)) + backend.send(request(expenseDefId)).map(handleResponse) + } + } + + object periods { + def list(): Future[List[Period]] = { + val request = interpreter.toRequest(Endpoints.client.periods.list, Some(baseUri)) + backend.send(request(())).map(handleResponse) + } + + def startNew(): Future[Period] = { + val request = interpreter.toRequest(Endpoints.client.periods.startNew, Some(baseUri)) + backend.send(request(())).map(handleResponse) + } + } + + object savingsAccounts { + def list(): Future[List[SavingsAccount]] = { + val request = interpreter.toRequest(Endpoints.client.savingsAccounts.list, Some(baseUri)) + backend.send(request(())).map(handleResponse) + } + + def create(dto: CreateSavingsAccount): Future[SavingsAccount] = { + val request = interpreter.toRequest(Endpoints.client.savingsAccounts.create, Some(baseUri)) + backend.send(request(dto)).map(handleResponse) + } + + def update(id: SavingsAccountId, dto: UpdateSavingsAccount): Future[SavingsAccount] = { + val request = interpreter.toRequest(Endpoints.client.savingsAccounts.update, Some(baseUri)) + backend.send(request((id, dto))).map(handleResponse) + } + + def updateBalance(id: SavingsAccountId, dto: UpdateSavingsAccountBalance): Future[SavingsAccount] = { + val request = interpreter.toRequest(Endpoints.client.savingsAccounts.updateBalance, Some(baseUri)) + backend.send(request((id, dto))).map(handleResponse) + } + + def delete(id: SavingsAccountId): Future[Unit] = { + val request = interpreter.toRequest(Endpoints.client.savingsAccounts.delete, Some(baseUri)) + backend.send(request(id)).map(handleResponse) + } + } + + object savingsTransactions { + def listCurrent(): Future[List[SavingsTransaction]] = { + val request = interpreter.toRequest(Endpoints.client.savingsTransactions.listCurrent, Some(baseUri)) + backend.send(request(())).map(handleResponse) + } + + def create(dto: CreateSavingsTransaction): Future[SavingsTransactionResponse] = { + val request = interpreter.toRequest(Endpoints.client.savingsTransactions.create, Some(baseUri)) + backend.send(request(dto)).map(handleResponse) + } + + def delete(id: SavingsTransactionId): Future[SavingsAccount] = { + val request = interpreter.toRequest(Endpoints.client.savingsTransactions.delete, Some(baseUri)) + backend.send(request(id)).map(handleResponse) + } + } + + object exchangeRates { + def getAll(): Future[List[ExchangeRate]] = { + val request = interpreter.toRequest(Endpoints.client.exchangeRates.getAll, Some(baseUri)) + backend.send(request(())).map(handleResponse) + } + } + + object currencies { + def getSettings(): Future[CurrencySettingsResponse] = { + val request = interpreter.toRequest(Endpoints.client.currencies.getSettings, Some(baseUri)) + backend.send(request(())).map(handleResponse) + } + + def enable(code: String): Future[CurrencySetting] = { + val request = interpreter.toRequest(Endpoints.client.currencies.enable, Some(baseUri)) + backend.send(request(EnableCurrencyRequest(code))).map(handleResponse) + } + + def disable(code: String): Future[Unit] = { + val request = interpreter.toRequest(Endpoints.client.currencies.disable, Some(baseUri)) + backend.send(request(code)).map(handleResponse) + } + + def setPrimary(code: String): Future[Unit] = { + val request = interpreter.toRequest(Endpoints.client.currencies.setPrimary, Some(baseUri)) + backend.send(request(SetPrimaryCurrencyRequest(code))).map(handleResponse) + } + + def refreshRates(): Future[ExchangeRatesResponse] = { + val request = interpreter.toRequest(Endpoints.client.currencies.refreshRates, Some(baseUri)) + backend.send(request(())).map(handleResponse) + } + } + + private def handleResponse[T](response: Response[DecodeResult[Either[String, T]]]): T = { + response.body match { + case DecodeResult.Value(Right(value)) => value + case DecodeResult.Value(Left(error)) => throw ApiException(error) + case failure: DecodeResult.Failure => throw ApiException(s"Decode failure: $failure") + } + } +} + +case class ApiException(message: String) extends Exception(message) diff --git a/frontend/src/main/scala/ssbudget/frontend/services/ApiDataService.scala b/frontend/src/main/scala/ssbudget/frontend/services/ApiDataService.scala new file mode 100644 index 0000000..8129c39 --- /dev/null +++ b/frontend/src/main/scala/ssbudget/frontend/services/ApiDataService.scala @@ -0,0 +1,420 @@ +package ssbudget.frontend.services + +import com.raquo.laminar.api.L.* +import ssbudget.shared.api.* +import ssbudget.shared.model.* + +import java.time.{Instant, LocalDate, ZoneId} +import java.time.temporal.ChronoUnit +import scala.concurrent.{ExecutionContext, Future} + +class ApiDataService(client: ApiClient)(implicit ec: ExecutionContext) extends DataService { + + // Mutable state for all entities + private val accountsVar: Var[List[Account]] = Var(List.empty) + private val balanceSnapshotsVar: Var[List[BalanceSnapshot]] = Var(List.empty) + private val budgetItemsVar: Var[List[BudgetItemDefinition]] = Var(List.empty) + private val budgetRecordsVar: Var[List[ExpenseRecord]] = Var(List.empty) + private val periodsVar: Var[List[Period]] = Var(List.empty) + private val exchangeRatesVar: Var[Map[Currency, Double]] = Var(Map.empty) + private val savingsAccountsVar: Var[List[SavingsAccount]] = Var(List.empty) + private val savingsTransactionsVar: Var[List[SavingsTransaction]] = Var(List.empty) + private val currencySettingsVar: Var[List[CurrencySetting]] = Var(List.empty) + private val availableCurrenciesVar: Var[List[(String, String)]] = Var(List.empty) + + // Initialize by fetching all data from individual endpoints + override def initialize(): Future[Unit] = { + // Fetch all data in parallel + val accountsFut = client.accounts.list() + val balancesFut = client.balances.listLatest() + val budgetItemsFut = client.budgetItems.list() + val periodsFut = client.periods.list() + val recordsFut = client.expenseRecords.listCurrent() + val savingsAccountsFut = client.savingsAccounts.list() + val savingsTxnsFut = client.savingsTransactions.listCurrent() + val exchangeRatesFut = client.exchangeRates.getAll() + val currencySettingsFut = client.currencies.getSettings() + + for { + accounts <- accountsFut + balances <- balancesFut + budgetItems <- budgetItemsFut + periods <- periodsFut + records <- recordsFut + savingsAccounts <- savingsAccountsFut + savingsTxns <- savingsTxnsFut + exchangeRates <- exchangeRatesFut + currencySettings <- currencySettingsFut + } yield { + accountsVar.set(accounts) + balanceSnapshotsVar.set(balances) + budgetItemsVar.set(budgetItems) + periodsVar.set(periods) + budgetRecordsVar.set(records) + savingsAccountsVar.set(savingsAccounts) + savingsTransactionsVar.set(savingsTxns) + // Convert List[ExchangeRate] to Map[Currency, Double] (fromCurrency -> rate) + exchangeRatesVar.set(exchangeRates.map(r => r.fromCurrency -> r.rateAsDouble).toMap) + currencySettingsVar.set(currencySettings.currencies) + availableCurrenciesVar.set(currencySettings.availableCurrencies.map(c => (c.code, c.name))) + } + } + + // Raw signals + override def accounts: Signal[List[Account]] = accountsVar.signal + override def balanceSnapshots: Signal[List[BalanceSnapshot]] = balanceSnapshotsVar.signal + override def budgetItems: Signal[List[BudgetItemDefinition]] = budgetItemsVar.signal + override def budgetRecords: Signal[List[ExpenseRecord]] = budgetRecordsVar.signal + override def periods: Signal[List[Period]] = periodsVar.signal + override def exchangeRates: Signal[Map[Currency, Double]] = exchangeRatesVar.signal + override def savingsAccounts: Signal[List[SavingsAccount]] = savingsAccountsVar.signal + override def savingsTransactions: Signal[List[SavingsTransaction]] = savingsTransactionsVar.signal + override def currencySettings: Signal[List[CurrencySetting]] = currencySettingsVar.signal + override def availableCurrencies: Signal[List[(String, String)]] = availableCurrenciesVar.signal + + override def enabledCurrencies: Signal[List[Currency]] = + currencySettingsVar.signal.map(_.map(_.code)) + + override def primaryCurrency: Signal[Currency] = + currencySettingsVar.signal.map(_.find(_.isPrimary).map(_.code).getOrElse(Currency.PLN)) + + // Derived signals + override def currentPeriod: Signal[Option[Period]] = + periodsVar.signal.map(_.find(_.endDate.isEmpty)) + + override def plannedExpenses: Signal[List[BudgetItemDefinition]] = + budgetItemsVar.signal.map(_.filter(_.itemType == BudgetItemType.PlannedExpense)) + + override def estimatedExpenses: Signal[List[BudgetItemDefinition]] = + budgetItemsVar.signal.map(_.filter(_.itemType == BudgetItemType.EstimatedExpense)) + + override def plannedIncomes: Signal[List[BudgetItemDefinition]] = + budgetItemsVar.signal.map(_.filter(_.itemType == BudgetItemType.PlannedIncome)) + + override def currentPeriodRecords: Signal[List[ExpenseRecord]] = + Signal + .combine(budgetRecordsVar.signal, currentPeriod) + .map { case (records, periodOpt) => + periodOpt.fold(List.empty[ExpenseRecord])(period => records.filter(_.periodId == period.id)) + } + + override def currentPeriodSavingsTransactions: Signal[List[SavingsTransaction]] = + Signal + .combine(savingsTransactionsVar.signal, currentPeriod) + .map { case (txns, periodOpt) => + periodOpt.fold(List.empty[SavingsTransaction])(period => txns.filter(_.periodId == period.id)) + } + + // Helper to sum Money in various currencies into primary currency + private def sumInPrimary(amounts: Seq[Money], rates: Map[Currency, Double], primary: Currency): Money = { + val total = amounts.foldLeft(0L) { (acc, money) => + val converted = + if money.currency == primary then money.amountCents + else rates.get(money.currency).map(rate => (money.amountCents * rate).toLong).getOrElse(money.amountCents) + acc + converted + } + Money(total, primary) + } + + override def bankAccountBalance: Signal[Money] = + balanceSnapshotsVar.signal + .combineWith(exchangeRatesVar.signal) + .combineWith(primaryCurrency) + .map { case (snapshots, rates, primary) => + sumInPrimary(snapshots.map(_.balance), rates, primary) + } + + override def totalBalance: Signal[Money] = + bankAccountBalance + .combineWith(savingsAccountsVar.signal) + .combineWith(exchangeRatesVar.signal) + .combineWith(primaryCurrency) + .map { case (bankBalance, savings, rates, primary) => + bankBalance + sumInPrimary(savings.map(_.balance), rates, primary) + } + + override def daysRemainingInPeriod: Signal[Int] = + currentPeriod.map { + case Some(_) => + val today = LocalDate.now(ZoneId.of("UTC")) + val day25 = today.withDayOfMonth(25) + val periodEnd = if today.getDayOfMonth < 25 then day25 else day25.plusMonths(1) + val daysLeft = ChronoUnit.DAYS.between(today, periodEnd).toInt + math.max(1, daysLeft) + case None => 0 + } + + override def unpaidPlannedExpenses: Signal[Money] = + plannedExpenses + .combineWith(currentPeriodRecords) + .combineWith(exchangeRatesVar.signal) + .combineWith(primaryCurrency) + .map { case (planned, records, rates, primary) => + val unpaidAmounts = planned.flatMap { exp => + val isPaid = records.exists(r => r.expenseDefId == exp.id && r.paidAmount.isDefined) + if isPaid then None else exp.estimateMoney + } + sumInPrimary(unpaidAmounts, rates, primary) + } + + override def scaledEstimatedExpenses: Signal[Money] = + estimatedExpenses + .combineWith(daysRemainingInPeriod) + .combineWith(exchangeRatesVar.signal) + .combineWith(primaryCurrency) + .map { case (estimated, daysRemaining, rates, primary) => + val scaleFactor = daysRemaining.toDouble / 30.0 + val scaledAmounts = estimated.flatMap { exp => + exp.estimateMoney.map(_ * scaleFactor) + } + sumInPrimary(scaledAmounts, rates, primary) + } + + override def remainingSavingsTarget: Signal[Money] = + savingsAccountsVar.signal + .combineWith(currentPeriodSavingsTransactions) + .combineWith(exchangeRatesVar.signal) + .combineWith(primaryCurrency) + .map { case (accounts, txns, rates, primary) => + val remainingAmounts = accounts.flatMap { account => + account.plannedMonthly.map { target => + val contributions = txns.filter(_.accountId == account.id).map(_.amount).sum + val remaining = math.max(0L, target - contributions) + Money(remaining, account.currency) + } + } + sumInPrimary(remainingAmounts, rates, primary) + } + + override def pendingIncome: Signal[Money] = + plannedIncomes + .combineWith(currentPeriodRecords) + .combineWith(exchangeRatesVar.signal) + .combineWith(primaryCurrency) + .map { case (incomes, records, rates, primary) => + val pendingAmounts = incomes.flatMap { inc => + val isReceived = records.exists(r => r.expenseDefId == inc.id && r.paidAmount.isDefined) + if isReceived then None else inc.estimateMoney + } + sumInPrimary(pendingAmounts, rates, primary) + } + + override def predictedExpenses: Signal[Money] = + unpaidPlannedExpenses + .combineWith(scaledEstimatedExpenses) + .combineWith(remainingSavingsTarget) + .map { case (unpaid, scaled, savings) => unpaid + scaled + savings } + + override def freeMoney: Signal[Money] = + bankAccountBalance + .combineWith(unpaidPlannedExpenses) + .combineWith(scaledEstimatedExpenses) + .combineWith(remainingSavingsTarget) + .combineWith(pendingIncome) + .map { case (bankBalance, unpaid, scaled, savings, income) => bankBalance - unpaid - scaled - savings + income } + + override def availableNow: Signal[Money] = + bankAccountBalance + .combineWith(unpaidPlannedExpenses) + .map { case (bankBalance, unpaid) => bankBalance - unpaid } + + override def dailyBudget: Signal[Money] = + freeMoney + .combineWith(daysRemainingInPeriod) + .map { case (free, days) => if days > 0 then free / days else Money.zero(free.currency) } + + // Mutation methods + override def addAccount(name: String, currency: Currency): Future[Unit] = { + client.accounts.create(CreateAccount(name, currency)).map { response => + accountsVar.update(_ :+ response.account) + balanceSnapshotsVar.update(_ :+ response.balance) + } + } + + override def deleteAccount(accountId: AccountId): Future[Unit] = { + client.accounts.delete(accountId).map { _ => + accountsVar.update(_.filterNot(_.id == accountId)) + balanceSnapshotsVar.update(_.filterNot(_.accountId == accountId)) + } + } + + override def updateAccountBalance(accountId: AccountId, amountCents: Long): Future[Unit] = { + client.balances.create(CreateBalanceSnapshot(accountId, amountCents)).map { snapshot => + balanceSnapshotsVar.update { snapshots => + snapshots.filterNot(_.accountId == accountId) :+ snapshot + } + } + } + + override def addBudgetItem(name: String, itemType: BudgetItemType, estimateCents: Long, currency: Currency): Future[Unit] = { + client.budgetItems.create(CreateBudgetItem(name, itemType, estimateCents, currency)).map { item => + budgetItemsVar.update(_ :+ item) + // If it's a planned expense or income, we need to refetch the records for current period + if itemType == BudgetItemType.PlannedExpense || itemType == BudgetItemType.PlannedIncome then { + // The backend will create the expense record; we need to refetch via bootstrap + // For now, optimistically add a pending record + getCurrentPeriod.foreach { period => + budgetRecordsVar.update { records => + records :+ ExpenseRecord( + ExpenseRecordId(s"temp-${System.currentTimeMillis()}"), + period.id, + item.id, + None, + None, + ) + } + } + } + } + } + + override def updateBudgetItemEstimate(itemId: ExpenseDefId, newEstimateCents: Long, currency: Currency): Future[Unit] = { + val current = budgetItemsVar.now().find(_.id == itemId) + current match { + case Some(item) => + client.budgetItems.update(itemId, UpdateBudgetItem(item.name, item.itemType, newEstimateCents, currency)).map { updated => + budgetItemsVar.update(items => items.map(i => if i.id == itemId then updated else i)) + } + case None => Future.failed(new Exception(s"Budget item not found: $itemId")) + } + } + + override def deleteBudgetItem(itemId: ExpenseDefId): Future[Unit] = { + client.budgetItems.delete(itemId).map { _ => + budgetItemsVar.update(_.filterNot(_.id == itemId)) + budgetRecordsVar.update(_.filterNot(_.expenseDefId == itemId)) + } + } + + override def markBudgetItemAsPaid(itemId: ExpenseDefId, amountCents: Long): Future[Unit] = { + client.expenseRecords.pay(itemId, PayBudgetItem(amountCents)).map { record => + budgetRecordsVar.update { records => + records.map(r => if r.expenseDefId == itemId && r.periodId == record.periodId then record else r) + } + } + } + + override def unmarkBudgetItemAsPaid(itemId: ExpenseDefId): Future[Unit] = { + client.expenseRecords.unpay(itemId).map { record => + budgetRecordsVar.update { records => + records.map(r => if r.expenseDefId == itemId && r.periodId == record.periodId then record else r) + } + } + } + + override def startNewPeriod(): Future[Unit] = { + client.periods.startNew().map { newPeriod => + // Close current period locally + periodsVar.update { ps => + ps.map { p => + if p.endDate.isEmpty then p.copy(endDate = Some(Instant.now())) + else p + } + } + // Add new period + periodsVar.update(_ :+ newPeriod) + // Reset current period records (they will be fetched from bootstrap if needed) + // For now, just create empty records for all planned items + val plannedItems = + budgetItemsVar.now().filter(item => item.itemType == BudgetItemType.PlannedExpense || item.itemType == BudgetItemType.PlannedIncome) + budgetRecordsVar.set( + plannedItems.map { item => + ExpenseRecord( + ExpenseRecordId(s"rec-${System.currentTimeMillis()}-${item.id.value}"), + newPeriod.id, + item.id, + None, + None, + ) + }, + ) + // Clear savings transactions for the new period + savingsTransactionsVar.set(List.empty) + } + } + + override def addSavingsAccount(name: String, currency: Currency, plannedMonthly: Option[Long]): Future[Unit] = { + client.savingsAccounts.create(CreateSavingsAccount(name, currency, plannedMonthly)).map { account => + savingsAccountsVar.update(_ :+ account) + } + } + + override def updateSavingsAccount( + id: SavingsAccountId, + name: String, + currency: Currency, + plannedMonthly: Option[Long], + ): Future[Unit] = { + client.savingsAccounts.update(id, UpdateSavingsAccount(name, currency, plannedMonthly)).map { updated => + savingsAccountsVar.update(accs => accs.map(a => if a.id == id then updated else a)) + } + } + + override def updateSavingsAccountBalance(id: SavingsAccountId, newBalance: Long): Future[Unit] = { + client.savingsAccounts.updateBalance(id, UpdateSavingsAccountBalance(newBalance)).map { updated => + savingsAccountsVar.update(accs => accs.map(a => if a.id == id then updated else a)) + } + } + + override def deleteSavingsAccount(id: SavingsAccountId): Future[Unit] = { + client.savingsAccounts.delete(id).map { _ => + savingsAccountsVar.update(_.filterNot(_.id == id)) + savingsTransactionsVar.update(_.filterNot(_.accountId == id)) + } + } + + override def addSavingsTransaction(accountId: SavingsAccountId, amount: Long, note: Option[String]): Future[Unit] = { + client.savingsTransactions.create(CreateSavingsTransaction(accountId, amount, note)).map { response => + savingsTransactionsVar.update(_ :+ response.transaction) + savingsAccountsVar.update(accs => accs.map(a => if a.id == accountId then response.updatedAccount else a)) + } + } + + override def deleteSavingsTransaction(id: SavingsTransactionId): Future[Unit] = { + val txnOpt = savingsTransactionsVar.now().find(_.id == id) + txnOpt match { + case Some(txn) => + client.savingsTransactions.delete(id).map { updatedAccount => + savingsTransactionsVar.update(_.filterNot(_.id == id)) + savingsAccountsVar.update(accs => accs.map(a => if a.id == txn.accountId then updatedAccount else a)) + } + case None => Future.successful(()) + } + } + + private def getCurrentPeriod: Option[Period] = + periodsVar.now().find(_.endDate.isEmpty) + + // Currency settings mutations + override def enableCurrency(code: String): Future[Unit] = { + client.currencies.enable(code).map { setting => + currencySettingsVar.update(_ :+ setting) + } + } + + override def disableCurrency(code: String): Future[Unit] = { + client.currencies.disable(code).map { _ => + currencySettingsVar.update(_.filterNot(_.code.code == code)) + } + } + + override def setPrimaryCurrency(code: String): Future[Unit] = { + client.currencies.setPrimary(code).map { _ => + currencySettingsVar.update { settings => + settings.map { s => + if s.code.code == code then s.copy(isPrimary = true) + else s.copy(isPrimary = false) + } + } + } + } + + override def refreshExchangeRates(): Future[Unit] = { + client.currencies.refreshRates().flatMap { response => + // After refreshing rates, fetch the updated rates + client.exchangeRates.getAll().map { rates => + exchangeRatesVar.set(rates.map(r => r.fromCurrency -> r.rateAsDouble).toMap) + } + } + } +} diff --git a/frontend/src/main/scala/ssbudget/frontend/services/DataService.scala b/frontend/src/main/scala/ssbudget/frontend/services/DataService.scala new file mode 100644 index 0000000..9179c1e --- /dev/null +++ b/frontend/src/main/scala/ssbudget/frontend/services/DataService.scala @@ -0,0 +1,86 @@ +package ssbudget.frontend.services + +import com.raquo.laminar.api.L.* +import org.scalajs.dom +import ssbudget.shared.model.* + +import scala.concurrent.Future + +trait DataService { + // Initialization (for API-backed implementations) + def initialize(): Future[Unit] + + // Accounts + def accounts: Signal[List[Account]] + def balanceSnapshots: Signal[List[BalanceSnapshot]] + def addAccount(name: String, currency: Currency): Future[Unit] + def deleteAccount(accountId: AccountId): Future[Unit] + def updateAccountBalance(accountId: AccountId, amountCents: Long): Future[Unit] + + // Budget items + def budgetItems: Signal[List[BudgetItemDefinition]] + def budgetRecords: Signal[List[ExpenseRecord]] + def addBudgetItem(name: String, itemType: BudgetItemType, estimateCents: Long, currency: Currency): Future[Unit] + def updateBudgetItemEstimate(itemId: ExpenseDefId, newEstimateCents: Long, currency: Currency): Future[Unit] + def deleteBudgetItem(itemId: ExpenseDefId): Future[Unit] + def markBudgetItemAsPaid(itemId: ExpenseDefId, amountCents: Long): Future[Unit] + def unmarkBudgetItemAsPaid(itemId: ExpenseDefId): Future[Unit] + + // Periods + def periods: Signal[List[Period]] + def startNewPeriod(): Future[Unit] + + // Exchange rates (currency code -> rate to primary currency) + def exchangeRates: Signal[Map[Currency, Double]] + + // Currency settings + def currencySettings: Signal[List[CurrencySetting]] + def availableCurrencies: Signal[List[(String, String)]] // (code, name) for dropdown + def enabledCurrencies: Signal[List[Currency]] + def primaryCurrency: Signal[Currency] + def enableCurrency(code: String): Future[Unit] + def disableCurrency(code: String): Future[Unit] + def setPrimaryCurrency(code: String): Future[Unit] + def refreshExchangeRates(): Future[Unit] + + // Savings accounts + def savingsAccounts: Signal[List[SavingsAccount]] + def savingsTransactions: Signal[List[SavingsTransaction]] + def currentPeriodSavingsTransactions: Signal[List[SavingsTransaction]] + def addSavingsAccount(name: String, currency: Currency, plannedMonthly: Option[Long]): Future[Unit] + def updateSavingsAccount(id: SavingsAccountId, name: String, currency: Currency, plannedMonthly: Option[Long]): Future[Unit] + def updateSavingsAccountBalance(id: SavingsAccountId, newBalance: Long): Future[Unit] + def deleteSavingsAccount(id: SavingsAccountId): Future[Unit] + def addSavingsTransaction(accountId: SavingsAccountId, amount: Long, note: Option[String]): Future[Unit] + def deleteSavingsTransaction(id: SavingsTransactionId): Future[Unit] + def remainingSavingsTarget: Signal[Money] // planned - actual contributions for current period + + // Derived signals + def currentPeriod: Signal[Option[Period]] + def plannedExpenses: Signal[List[BudgetItemDefinition]] + def estimatedExpenses: Signal[List[BudgetItemDefinition]] + def plannedIncomes: Signal[List[BudgetItemDefinition]] + def currentPeriodRecords: Signal[List[ExpenseRecord]] + + def unpaidPlannedExpenses: Signal[Money] + def scaledEstimatedExpenses: Signal[Money] + def pendingIncome: Signal[Money] + def predictedExpenses: Signal[Money] + def freeMoney: Signal[Money] // bankAccountBalance - predicted expenses - remaining savings + pending income + def availableNow: Signal[Money] // bankAccountBalance - unpaid planned only (conservative estimate) + def dailyBudget: Signal[Money] + def bankAccountBalance: Signal[Money] // only bank accounts, not savings + def totalBalance: Signal[Money] // all accounts including savings (for accounts table footer) + def daysRemainingInPeriod: Signal[Int] +} + +object DataService { + import scala.concurrent.ExecutionContext.Implicits.global + + private lazy val apiService = new ApiDataService(new ApiClient()) + + lazy val instance: DataService = { + if dom.window.location.search.contains("mock=true") then InMemoryDataService + else apiService + } +} diff --git a/frontend/src/main/scala/ssbudget/frontend/services/InMemoryDataService.scala b/frontend/src/main/scala/ssbudget/frontend/services/InMemoryDataService.scala new file mode 100644 index 0000000..8cec0b2 --- /dev/null +++ b/frontend/src/main/scala/ssbudget/frontend/services/InMemoryDataService.scala @@ -0,0 +1,495 @@ +package ssbudget.frontend.services + +import com.raquo.laminar.api.L.* +import ssbudget.shared.model.* + +import java.time.{Instant, LocalDate, ZoneId} +import java.time.temporal.ChronoUnit +import scala.concurrent.Future + +object InMemoryDataService extends DataService { + + override def initialize(): Future[Unit] = Future.successful(()) + + private val now = Instant.now() + private val tenDaysAgo = now.minus(10, ChronoUnit.DAYS) + private val thirtyDaysAgo = now.minus(30, ChronoUnit.DAYS) + private val sixtyDaysAgo = now.minus(60, ChronoUnit.DAYS) + + private val accountsVar: Var[List[Account]] = Var( + List( + Account(AccountId("acc-1"), "Main PLN", Currency.PLN), + Account(AccountId("acc-2"), "Savings PLN", Currency.PLN), + Account(AccountId("acc-3"), "Euro Account", Currency.EUR), + ), + ) + + private val balanceSnapshotsVar: Var[List[BalanceSnapshot]] = Var( + List( + BalanceSnapshot(BalanceSnapshotId("snap-1"), AccountId("acc-1"), 450000, Currency.PLN, now), + BalanceSnapshot(BalanceSnapshotId("snap-2"), AccountId("acc-2"), 1000000, Currency.PLN, now), + BalanceSnapshot(BalanceSnapshotId("snap-3"), AccountId("acc-3"), 50000, Currency.EUR, now), + ), + ) + + private val budgetItemsVar: Var[List[BudgetItemDefinition]] = Var( + List( + // Planned expenses + BudgetItemDefinition(ExpenseDefId("exp-1"), "Rent", BudgetItemType.PlannedExpense, EstimateMode.Fixed, Some(250000), Currency.PLN), + BudgetItemDefinition(ExpenseDefId("exp-2"), "Electricity", BudgetItemType.PlannedExpense, EstimateMode.LastMonth, Some(15000), Currency.PLN), + BudgetItemDefinition(ExpenseDefId("exp-3"), "Netflix", BudgetItemType.PlannedExpense, EstimateMode.Fixed, Some(5500), Currency.PLN), + // Estimated expenses + BudgetItemDefinition(ExpenseDefId("exp-4"), "Groceries", BudgetItemType.EstimatedExpense, EstimateMode.Fixed, Some(150000), Currency.PLN), + BudgetItemDefinition(ExpenseDefId("exp-5"), "Fuel", BudgetItemType.EstimatedExpense, EstimateMode.Average, Some(60000), Currency.PLN), + BudgetItemDefinition(ExpenseDefId("exp-6"), "Entertainment", BudgetItemType.EstimatedExpense, EstimateMode.Fixed, Some(30000), Currency.PLN), + // Planned incomes + BudgetItemDefinition(ExpenseDefId("inc-1"), "Freelance Project", BudgetItemType.PlannedIncome, EstimateMode.Fixed, Some(200000), Currency.PLN), + BudgetItemDefinition(ExpenseDefId("inc-2"), "Tax Refund", BudgetItemType.PlannedIncome, EstimateMode.Fixed, Some(50000), Currency.PLN), + ), + ) + + private val periodsVar: Var[List[Period]] = Var( + List( + Period(PeriodId("period-1"), tenDaysAgo, None), + Period(PeriodId("period-0"), sixtyDaysAgo, Some(thirtyDaysAgo)), + ), + ) + + private val budgetRecordsVar: Var[List[ExpenseRecord]] = Var( + List( + // Expense records + ExpenseRecord(ExpenseRecordId("rec-1"), PeriodId("period-1"), ExpenseDefId("exp-1"), Some(250000), Some(tenDaysAgo.plus(1, ChronoUnit.DAYS))), + ExpenseRecord(ExpenseRecordId("rec-2"), PeriodId("period-1"), ExpenseDefId("exp-2"), Some(17500), Some(tenDaysAgo.plus(2, ChronoUnit.DAYS))), + ExpenseRecord(ExpenseRecordId("rec-3"), PeriodId("period-1"), ExpenseDefId("exp-3"), None, None), + // Income records (not yet received) + ExpenseRecord(ExpenseRecordId("rec-4"), PeriodId("period-1"), ExpenseDefId("inc-1"), None, None), + ExpenseRecord(ExpenseRecordId("rec-5"), PeriodId("period-1"), ExpenseDefId("inc-2"), None, None), + ), + ) + + private val exchangeRatesVar: Var[Map[Currency, Double]] = Var( + Map(Currency.EUR -> 4.32), + ) + + private val savingsAccountsVar: Var[List[SavingsAccount]] = Var( + List( + SavingsAccount(SavingsAccountId("sav-1"), "Emergency Fund", Currency.PLN, 500000, Some(50000)), + SavingsAccount(SavingsAccountId("sav-2"), "Vacation", Currency.EUR, 30000, Some(20000)), + SavingsAccount(SavingsAccountId("sav-3"), "New Laptop", Currency.PLN, 200000, None), + ), + ) + + private val savingsTransactionsVar: Var[List[SavingsTransaction]] = Var( + List( + SavingsTransaction( + SavingsTransactionId("stxn-1"), + SavingsAccountId("sav-1"), + PeriodId("period-1"), + 50000, + Some("Monthly contribution"), + tenDaysAgo.plus(2, ChronoUnit.DAYS), + ), + SavingsTransaction( + SavingsTransactionId("stxn-2"), + SavingsAccountId("sav-1"), + PeriodId("period-1"), + -10000, + Some("Small emergency"), + tenDaysAgo.plus(5, ChronoUnit.DAYS), + ), + SavingsTransaction( + SavingsTransactionId("stxn-3"), + SavingsAccountId("sav-2"), + PeriodId("period-1"), + 15000, + None, + tenDaysAgo.plus(3, ChronoUnit.DAYS), + ), + ), + ) + + private val currencySettingsVar: Var[List[CurrencySetting]] = Var( + List( + CurrencySetting(Currency.PLN, "Polish Zloty", isPrimary = true, now), + CurrencySetting(Currency.EUR, "Euro", isPrimary = false, now), + ), + ) + + override def accounts: Signal[List[Account]] = accountsVar.signal + override def balanceSnapshots: Signal[List[BalanceSnapshot]] = balanceSnapshotsVar.signal + override def budgetItems: Signal[List[BudgetItemDefinition]] = budgetItemsVar.signal + override def budgetRecords: Signal[List[ExpenseRecord]] = budgetRecordsVar.signal + override def periods: Signal[List[Period]] = periodsVar.signal + override def exchangeRates: Signal[Map[Currency, Double]] = exchangeRatesVar.signal + override def savingsAccounts: Signal[List[SavingsAccount]] = savingsAccountsVar.signal + override def savingsTransactions: Signal[List[SavingsTransaction]] = savingsTransactionsVar.signal + override def currencySettings: Signal[List[CurrencySetting]] = currencySettingsVar.signal + override def availableCurrencies: Signal[List[(String, String)]] = Val(Currency.knownCurrencies) + override def enabledCurrencies: Signal[List[Currency]] = currencySettingsVar.signal.map(_.map(_.code)) + override def primaryCurrency: Signal[Currency] = currencySettingsVar.signal.map(_.find(_.isPrimary).map(_.code).getOrElse(Currency.PLN)) + + override def currentPeriod: Signal[Option[Period]] = + periodsVar.signal.map(_.find(_.endDate.isEmpty)) + + // Helper to sum Money in various currencies into primary currency + private def sumInPrimary(amounts: Seq[Money], rates: Map[Currency, Double], primary: Currency): Money = { + val total = amounts.foldLeft(0L) { (acc, money) => + val converted = + if money.currency == primary then money.amountCents + else rates.get(money.currency).map(rate => (money.amountCents * rate).toLong).getOrElse(money.amountCents) + acc + converted + } + Money(total, primary) + } + + override def bankAccountBalance: Signal[Money] = + balanceSnapshotsVar.signal + .combineWith(exchangeRatesVar.signal) + .combineWith(primaryCurrency) + .map { case (snapshots, rates, primary) => + sumInPrimary(snapshots.map(_.balance), rates, primary) + } + + override def totalBalance: Signal[Money] = + bankAccountBalance + .combineWith(savingsAccountsVar.signal) + .combineWith(exchangeRatesVar.signal) + .combineWith(primaryCurrency) + .map { case (bankBalance, savings, rates, primary) => + bankBalance + sumInPrimary(savings.map(_.balance), rates, primary) + } + + override def plannedExpenses: Signal[List[BudgetItemDefinition]] = + budgetItemsVar.signal.map(_.filter(_.itemType == BudgetItemType.PlannedExpense)) + + override def estimatedExpenses: Signal[List[BudgetItemDefinition]] = + budgetItemsVar.signal.map(_.filter(_.itemType == BudgetItemType.EstimatedExpense)) + + override def plannedIncomes: Signal[List[BudgetItemDefinition]] = + budgetItemsVar.signal.map(_.filter(_.itemType == BudgetItemType.PlannedIncome)) + + override def currentPeriodRecords: Signal[List[ExpenseRecord]] = + Signal + .combine(budgetRecordsVar.signal, currentPeriod) + .map { case (records, periodOpt) => + periodOpt.fold(List.empty[ExpenseRecord])(period => records.filter(_.periodId == period.id)) + } + + override def unpaidPlannedExpenses: Signal[Money] = + plannedExpenses + .combineWith(currentPeriodRecords) + .combineWith(exchangeRatesVar.signal) + .combineWith(primaryCurrency) + .map { case (planned, records, rates, primary) => + val unpaidAmounts = planned.flatMap { exp => + val isPaid = records.exists(r => r.expenseDefId == exp.id && r.paidAmount.isDefined) + if isPaid then None else exp.estimateMoney + } + sumInPrimary(unpaidAmounts, rates, primary) + } + + override def daysRemainingInPeriod: Signal[Int] = + currentPeriod.map { + case Some(_) => + val today = LocalDate.now(ZoneId.of("UTC")) + val day25 = today.withDayOfMonth(25) + val periodEnd = if today.getDayOfMonth < 25 then day25 else day25.plusMonths(1) + val daysLeft = ChronoUnit.DAYS.between(today, periodEnd).toInt + math.max(1, daysLeft) + case None => 0 + } + + override def scaledEstimatedExpenses: Signal[Money] = + estimatedExpenses + .combineWith(daysRemainingInPeriod) + .combineWith(exchangeRatesVar.signal) + .combineWith(primaryCurrency) + .map { case (estimated, daysRemaining, rates, primary) => + val scaleFactor = daysRemaining.toDouble / 30.0 + val scaledAmounts = estimated.flatMap { exp => + exp.estimateMoney.map(_ * scaleFactor) + } + sumInPrimary(scaledAmounts, rates, primary) + } + + override def currentPeriodSavingsTransactions: Signal[List[SavingsTransaction]] = + Signal + .combine(savingsTransactionsVar.signal, currentPeriod) + .map { case (txns, periodOpt) => + periodOpt.fold(List.empty[SavingsTransaction])(period => txns.filter(_.periodId == period.id)) + } + + override def remainingSavingsTarget: Signal[Money] = + savingsAccountsVar.signal + .combineWith(currentPeriodSavingsTransactions) + .combineWith(exchangeRatesVar.signal) + .combineWith(primaryCurrency) + .map { case (accounts, txns, rates, primary) => + val remainingAmounts = accounts.flatMap { account => + account.plannedMonthly.map { target => + val contributions = txns.filter(_.accountId == account.id).map(_.amount).sum + val remaining = math.max(0L, target - contributions) + Money(remaining, account.currency) + } + } + sumInPrimary(remainingAmounts, rates, primary) + } + + override def pendingIncome: Signal[Money] = + plannedIncomes + .combineWith(currentPeriodRecords) + .combineWith(exchangeRatesVar.signal) + .combineWith(primaryCurrency) + .map { case (incomes, records, rates, primary) => + val pendingAmounts = incomes.flatMap { inc => + val isReceived = records.exists(r => r.expenseDefId == inc.id && r.paidAmount.isDefined) + if isReceived then None else inc.estimateMoney + } + sumInPrimary(pendingAmounts, rates, primary) + } + + override def predictedExpenses: Signal[Money] = + unpaidPlannedExpenses + .combineWith(scaledEstimatedExpenses) + .combineWith(remainingSavingsTarget) + .map { case (unpaid, scaled, savings) => unpaid + scaled + savings } + + override def freeMoney: Signal[Money] = + bankAccountBalance + .combineWith(unpaidPlannedExpenses) + .combineWith(scaledEstimatedExpenses) + .combineWith(remainingSavingsTarget) + .combineWith(pendingIncome) + .map { case (bankBalance, unpaid, scaled, savings, income) => bankBalance - unpaid - scaled - savings + income } + + override def availableNow: Signal[Money] = + bankAccountBalance + .combineWith(unpaidPlannedExpenses) + .map { case (bankBalance, unpaid) => bankBalance - unpaid } + + override def dailyBudget: Signal[Money] = + freeMoney + .combineWith(daysRemainingInPeriod) + .map { case (free, days) => if days > 0 then free / days else Money.zero(free.currency) } + + override def addAccount(name: String, currency: Currency): Future[Unit] = { + val newId = AccountId(s"acc-${System.currentTimeMillis()}") + accountsVar.update(_ :+ Account(newId, name, currency)) + balanceSnapshotsVar.update { snaps => + snaps :+ BalanceSnapshot( + BalanceSnapshotId(s"snap-${System.currentTimeMillis()}"), + newId, + 0L, + currency, + Instant.now(), + ) + } + Future.successful(()) + } + + override def deleteAccount(accountId: AccountId): Future[Unit] = { + accountsVar.update(_.filterNot(_.id == accountId)) + balanceSnapshotsVar.update(_.filterNot(_.accountId == accountId)) + Future.successful(()) + } + + override def updateAccountBalance(accountId: AccountId, amountCents: Long): Future[Unit] = { + val account = accountsVar.now().find(_.id == accountId) + account.foreach { acc => + balanceSnapshotsVar.update { snapshots => + val newSnapshot = BalanceSnapshot( + BalanceSnapshotId(s"snap-${System.currentTimeMillis()}"), + accountId, + amountCents, + acc.currency, + Instant.now(), + ) + snapshots.filterNot(_.accountId == accountId) :+ newSnapshot + } + } + Future.successful(()) + } + + override def addBudgetItem(name: String, itemType: BudgetItemType, estimateCents: Long, currency: Currency): Future[Unit] = { + val newId = ExpenseDefId(s"item-${System.currentTimeMillis()}") + val newDef = BudgetItemDefinition(newId, name, itemType, EstimateMode.Fixed, Some(estimateCents), currency) + budgetItemsVar.update(_ :+ newDef) + + if itemType == BudgetItemType.PlannedExpense || itemType == BudgetItemType.PlannedIncome then { + getCurrentPeriod.foreach { period => + budgetRecordsVar.update { records => + records :+ ExpenseRecord( + ExpenseRecordId(s"rec-${System.currentTimeMillis()}"), + period.id, + newId, + None, + None, + ) + } + } + } + Future.successful(()) + } + + override def updateBudgetItemEstimate(itemId: ExpenseDefId, newEstimateCents: Long, currency: Currency): Future[Unit] = { + budgetItemsVar.update { defs => + defs.map { item => + if item.id == itemId then item.copy(fixedEstimate = Some(newEstimateCents), currency = currency) + else item + } + } + Future.successful(()) + } + + override def deleteBudgetItem(itemId: ExpenseDefId): Future[Unit] = { + budgetItemsVar.update(_.filterNot(_.id == itemId)) + budgetRecordsVar.update(_.filterNot(_.expenseDefId == itemId)) + Future.successful(()) + } + + override def markBudgetItemAsPaid(itemId: ExpenseDefId, amountCents: Long): Future[Unit] = { + getCurrentPeriod.foreach { period => + budgetRecordsVar.update { records => + records.map { rec => + if rec.expenseDefId == itemId && rec.periodId == period.id then rec.copy(paidAmount = Some(amountCents), paidAt = Some(Instant.now())) + else rec + } + } + } + Future.successful(()) + } + + override def unmarkBudgetItemAsPaid(itemId: ExpenseDefId): Future[Unit] = { + getCurrentPeriod.foreach { period => + budgetRecordsVar.update { records => + records.map { rec => + if rec.expenseDefId == itemId && rec.periodId == period.id then rec.copy(paidAmount = None, paidAt = None) + else rec + } + } + } + Future.successful(()) + } + + override def startNewPeriod(): Future[Unit] = { + val now = Instant.now() + + periodsVar.update { ps => + ps.map { p => + if p.endDate.isEmpty then p.copy(endDate = Some(now)) + else p + } + } + + val newPeriodId = PeriodId(s"period-${System.currentTimeMillis()}") + periodsVar.update(_ :+ Period(newPeriodId, now, None)) + + val plannedItems = + budgetItemsVar.now().filter(item => item.itemType == BudgetItemType.PlannedExpense || item.itemType == BudgetItemType.PlannedIncome) + budgetRecordsVar.update { records => + records ++ plannedItems.map { item => + ExpenseRecord( + ExpenseRecordId(s"rec-${System.currentTimeMillis()}-${item.id.value}"), + newPeriodId, + item.id, + None, + None, + ) + } + } + Future.successful(()) + } + + private def getCurrentPeriod: Option[Period] = + periodsVar.now().find(_.endDate.isEmpty) + + override def addSavingsAccount(name: String, currency: Currency, plannedMonthly: Option[Long]): Future[Unit] = { + val newId = SavingsAccountId(s"sav-${System.currentTimeMillis()}") + savingsAccountsVar.update(_ :+ SavingsAccount(newId, name, currency, 0L, plannedMonthly)) + Future.successful(()) + } + + override def updateSavingsAccount(id: SavingsAccountId, name: String, currency: Currency, plannedMonthly: Option[Long]): Future[Unit] = { + savingsAccountsVar.update { accounts => + accounts.map { acc => + if acc.id == id then acc.copy(name = name, currency = currency, plannedMonthly = plannedMonthly) + else acc + } + } + Future.successful(()) + } + + override def updateSavingsAccountBalance(id: SavingsAccountId, newBalance: Long): Future[Unit] = { + savingsAccountsVar.update { accounts => + accounts.map { acc => + if acc.id == id then acc.copy(currentBalance = newBalance) + else acc + } + } + Future.successful(()) + } + + override def deleteSavingsAccount(id: SavingsAccountId): Future[Unit] = { + savingsAccountsVar.update(_.filterNot(_.id == id)) + savingsTransactionsVar.update(_.filterNot(_.accountId == id)) + Future.successful(()) + } + + override def addSavingsTransaction(accountId: SavingsAccountId, amount: Long, note: Option[String]): Future[Unit] = { + getCurrentPeriod.foreach { period => + val txnId = SavingsTransactionId(s"stxn-${System.currentTimeMillis()}") + val txn = SavingsTransaction(txnId, accountId, period.id, amount, note, Instant.now()) + savingsTransactionsVar.update(_ :+ txn) + // Update the account balance + savingsAccountsVar.update { accounts => + accounts.map { acc => + if acc.id == accountId then acc.copy(currentBalance = acc.currentBalance + amount) + else acc + } + } + } + Future.successful(()) + } + + override def deleteSavingsTransaction(id: SavingsTransactionId): Future[Unit] = { + val txnOpt = savingsTransactionsVar.now().find(_.id == id) + txnOpt.foreach { txn => + // Reverse the balance change + savingsAccountsVar.update { accounts => + accounts.map { acc => + if acc.id == txn.accountId then acc.copy(currentBalance = acc.currentBalance - txn.amount) + else acc + } + } + savingsTransactionsVar.update(_.filterNot(_.id == id)) + } + Future.successful(()) + } + + override def enableCurrency(code: String): Future[Unit] = { + val name = Currency.nameFor(code).getOrElse(code) + val setting = CurrencySetting(Currency(code), name, isPrimary = false, Instant.now()) + currencySettingsVar.update(_ :+ setting) + Future.successful(()) + } + + override def disableCurrency(code: String): Future[Unit] = { + currencySettingsVar.update(_.filterNot(_.code.code == code)) + Future.successful(()) + } + + override def setPrimaryCurrency(code: String): Future[Unit] = { + currencySettingsVar.update { settings => + settings.map { s => + if s.code.code == code then s.copy(isPrimary = true) + else s.copy(isPrimary = false) + } + } + Future.successful(()) + } + + override def refreshExchangeRates(): Future[Unit] = { + // Mock implementation - rates stay the same + Future.successful(()) + } +} diff --git a/frontend/src/main/scala/ssbudget/frontend/util/Formatting.scala b/frontend/src/main/scala/ssbudget/frontend/util/Formatting.scala new file mode 100644 index 0000000..5ece2e5 --- /dev/null +++ b/frontend/src/main/scala/ssbudget/frontend/util/Formatting.scala @@ -0,0 +1,47 @@ +package ssbudget.frontend.util + +import java.time.{Instant, LocalDate, ZoneId} +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit + +object Formatting { + + private val dateFormatter = DateTimeFormatter.ofPattern("MMM d, yyyy") + private val shortDateFormatter = DateTimeFormatter.ofPattern("MMM d") + // Use UTC instead of systemDefault() - systemDefault() fails silently in Scala.js + // without the scala-java-time-tzdb dependency + private val zone = ZoneId.of("UTC") + + def formatMoneyShort(cents: Long): String = { + val amount = cents / 100.0 + f"$amount%,.0f" + } + + def formatDate(instant: Instant): String = { + val localDate = instant.atZone(zone).toLocalDate + dateFormatter.format(localDate) + } + + def formatLocalDate(date: LocalDate): String = { + dateFormatter.format(date) + } + + def formatDateShort(instant: Instant): String = { + val localDate = instant.atZone(zone).toLocalDate + shortDateFormatter.format(localDate) + } + + def daysRemaining(from: Instant, assumedPeriodDays: Int = 30): Int = { + val daysSinceStart = ChronoUnit.DAYS.between(from, Instant.now()).toInt + math.max(1, assumedPeriodDays - daysSinceStart) + } + + def daysElapsed(from: Instant): Int = { + ChronoUnit.DAYS.between(from, Instant.now()).toInt + } + + def periodProgress(startDate: Instant, totalDays: Int = 30): Int = { + val elapsed = daysElapsed(startDate) + math.min(100, (elapsed * 100) / totalDays) + } +} diff --git a/frontend/src/main/scala/ssbudget/frontend/util/MoneyFormatter.scala b/frontend/src/main/scala/ssbudget/frontend/util/MoneyFormatter.scala new file mode 100644 index 0000000..475c14f --- /dev/null +++ b/frontend/src/main/scala/ssbudget/frontend/util/MoneyFormatter.scala @@ -0,0 +1,86 @@ +package ssbudget.frontend.util + +import com.raquo.laminar.api.L.* +import ssbudget.shared.model.{Currency, Money} + +object MoneyFormatter { + + private val primaryCurrencyVar: Var[Currency] = Var(Currency.PLN) + private val exchangeRatesVar: Var[Map[Currency, Double]] = Var(Map.empty) + + /** Initialize the formatter with reactive signals from DataService. Call once during app startup. */ + def init(primaryCurrency: Signal[Currency], exchangeRates: Signal[Map[Currency, Double]]): Unit = { + // Subscribe to updates (these subscriptions live for the app lifetime) + import com.raquo.airstream.ownership.OneTimeOwner + given owner: OneTimeOwner = new OneTimeOwner(() => ()) + primaryCurrency.foreach(primaryCurrencyVar.set) + exchangeRates.foreach(exchangeRatesVar.set) + } + + /** Current primary currency (reactive) */ + def primaryCurrency: Signal[Currency] = primaryCurrencyVar.signal + + /** Get current primary currency value (for non-reactive contexts) */ + def primary: Currency = primaryCurrencyVar.now() + + /** Format cents in the primary currency */ + def formatPrimary(cents: Long): HtmlElement = format(cents, primaryCurrencyVar.now()) + + /** Format money as a simple string (e.g., "1,234.56 PLN") */ + def formatSimple(money: Money): String = { + f"${money.amountCents / 100.0}%,.2f ${money.currency.code}" + } + + /** Format cents as a simple string */ + def formatSimple(cents: Long, currency: Currency): String = { + formatSimple(Money(cents, currency)) + } + + /** Format money as a Laminar element with currency conversion if needed. */ + def format(money: Money): HtmlElement = { + formatWithContext(money, primaryCurrencyVar.now(), exchangeRatesVar.now()) + } + + /** Format cents as a Laminar element with currency conversion if needed. */ + def format(cents: Long, currency: Currency): HtmlElement = { + format(Money(cents, currency)) + } + + /** Reactive format that updates when primary currency or rates change. */ + def formatReactive(money: Signal[Money]): Signal[HtmlElement] = { + money + .combineWith(primaryCurrencyVar.signal) + .combineWith(exchangeRatesVar.signal) + .map { case (m, primary, rates) => formatWithContext(m, primary, rates) } + } + + /** Format a Money signal as a reactive child element. */ + def formatChild(money: Signal[Money]): Modifier[HtmlElement] = { + child <-- formatReactive(money) + } + + private def formatWithContext(money: Money, primary: Currency, rates: Map[Currency, Double]): HtmlElement = { + if money.currency == primary then { + span(cls := "money-primary", formatSimple(money)) + } else { + val converted = convertToPrimary(money, primary, rates) + span( + cls := "money-foreign d-inline-flex flex-column align-items-end", + span(cls := "money-amount", formatSimple(money)), + span(cls := "money-equivalent text-muted small", s"~${formatSimple(converted)}"), + ) + } + } + + /** Convert money to primary currency using rates map */ + def convertToPrimary(money: Money, primary: Currency, rates: Map[Currency, Double]): Money = { + if money.currency == primary then money + else { + rates.get(money.currency) match { + case Some(rate) => Money((money.amountCents * rate).toLong, primary) + case None => Money(money.amountCents, primary) // No rate available, keep amount (imperfect fallback) + } + } + } + +} diff --git a/frontend/src/main/scala/ssbudget/frontend/util/WebAuthnFacade.scala b/frontend/src/main/scala/ssbudget/frontend/util/WebAuthnFacade.scala new file mode 100644 index 0000000..dc65319 --- /dev/null +++ b/frontend/src/main/scala/ssbudget/frontend/util/WebAuthnFacade.scala @@ -0,0 +1,166 @@ +package ssbudget.frontend.util + +import org.scalajs.dom +import ssbudget.shared.api.* + +import scala.concurrent.{ExecutionContext, Future} +import scala.scalajs.js +import scala.scalajs.js.typedarray.{ArrayBuffer, Uint8Array} + +object WebAuthnFacade { + + def isSupported: Boolean = { + !js.isUndefined(dom.window.asInstanceOf[js.Dynamic].PublicKeyCredential) + } + + def createCredential( + options: PasskeyRegistrationOptions, + )(implicit ec: ExecutionContext): Future[PasskeyRegistrationResponse] = { + val publicKeyOptions = js.Dynamic.literal( + challenge = base64UrlToArrayBuffer(options.challenge), + rp = js.Dynamic.literal( + id = options.rpId, + name = options.rpName, + ), + user = js.Dynamic.literal( + id = base64UrlToArrayBuffer(options.userId), + name = options.userName, + displayName = options.userName, + ), + pubKeyCredParams = js.Array( + options.pubKeyCredParams.map { param => + js.Dynamic.literal( + `type` = param.`type`, + alg = param.alg, + ) + }*, + ), + timeout = options.timeout.toDouble, + attestation = options.attestation, + authenticatorSelection = js.Dynamic.literal( + authenticatorAttachment = options.authenticatorSelection.authenticatorAttachment.orNull, + residentKey = options.authenticatorSelection.residentKey, + userVerification = options.authenticatorSelection.userVerification, + ), + ) + + val createOptions = js.Dynamic.literal( + publicKey = publicKeyOptions, + ) + + val credentials = dom.window.navigator.asInstanceOf[js.Dynamic].credentials + credentials + .create(createOptions) + .asInstanceOf[js.Promise[js.Dynamic]] + .toFuture + .map { credential => + val response = credential.response + val rawId = credential.rawId.asInstanceOf[ArrayBuffer] + val id = credential.id.asInstanceOf[String] + + val clientDataJSON = arrayBufferToBase64Url(response.clientDataJSON.asInstanceOf[ArrayBuffer]) + val attestationObject = arrayBufferToBase64Url(response.attestationObject.asInstanceOf[ArrayBuffer]) + + val transports: Option[List[String]] = { + if !js.isUndefined(response.getTransports) then { + val arr = response.getTransports().asInstanceOf[js.Array[String]] + Some(arr.toList) + } else { + None + } + } + + PasskeyRegistrationResponse( + id = id, + rawId = arrayBufferToBase64Url(rawId), + response = AttestationResponse( + clientDataJSON = clientDataJSON, + attestationObject = attestationObject, + transports = transports, + ), + `type` = "public-key", + clientExtensionResults = None, + ) + } + } + + def getCredential( + options: PasskeyAuthenticationOptions, + )(implicit ec: ExecutionContext): Future[PasskeyAuthenticationResponse] = { + val allowCredentials = js.Array( + options.allowCredentials.map { cred => + js.Dynamic.literal( + `type` = cred.`type`, + id = base64UrlToArrayBuffer(cred.id), + transports = cred.transports.map(t => js.Array(t*)).getOrElse(js.undefined), + ) + }*, + ) + + val publicKeyOptions = js.Dynamic.literal( + challenge = base64UrlToArrayBuffer(options.challenge), + rpId = options.rpId, + timeout = options.timeout.toDouble, + userVerification = options.userVerification, + allowCredentials = allowCredentials, + ) + + val getOptions = js.Dynamic.literal( + publicKey = publicKeyOptions, + ) + + val credentials = dom.window.navigator.asInstanceOf[js.Dynamic].credentials + credentials + .get(getOptions) + .asInstanceOf[js.Promise[js.Dynamic]] + .toFuture + .map { credential => + val response = credential.response + val rawId = credential.rawId.asInstanceOf[ArrayBuffer] + val id = credential.id.asInstanceOf[String] + + val clientDataJSON = arrayBufferToBase64Url(response.clientDataJSON.asInstanceOf[ArrayBuffer]) + val authenticatorData = arrayBufferToBase64Url(response.authenticatorData.asInstanceOf[ArrayBuffer]) + val signature = arrayBufferToBase64Url(response.signature.asInstanceOf[ArrayBuffer]) + val userHandle = if js.isUndefined(response.userHandle) || response.userHandle == null then { + None + } else { + Some(arrayBufferToBase64Url(response.userHandle.asInstanceOf[ArrayBuffer])) + } + + PasskeyAuthenticationResponse( + id = id, + rawId = arrayBufferToBase64Url(rawId), + response = AssertionResponse( + clientDataJSON = clientDataJSON, + authenticatorData = authenticatorData, + signature = signature, + userHandle = userHandle, + ), + `type` = "public-key", + clientExtensionResults = None, + ) + } + } + + private def base64UrlToArrayBuffer(base64url: String): ArrayBuffer = { + // Convert base64url to standard base64 + val base64 = base64url.replace('-', '+').replace('_', '/') + val padded = base64 + "=" * ((4 - base64.length % 4) % 4) + + val binary = dom.window.atob(padded) + val bytes = new Uint8Array(binary.length) + for i <- 0 until binary.length do { + bytes(i) = binary.charAt(i).toByte + } + bytes.buffer + } + + private def arrayBufferToBase64Url(buffer: ArrayBuffer): String = { + val bytes = new Uint8Array(buffer) + val binary = (0 until bytes.length).map(i => bytes(i).toChar).mkString + val base64 = dom.window.btoa(binary) + // Convert to base64url + base64.replace('+', '-').replace('/', '_').replace("=", "") + } +} diff --git a/frontend/vite.config.e2e.mjs b/frontend/vite.config.e2e.mjs new file mode 100644 index 0000000..f81da30 --- /dev/null +++ b/frontend/vite.config.e2e.mjs @@ -0,0 +1,23 @@ +import { defineConfig } from 'vite' +import scalaJSPlugin from "@scala-js/vite-plugin-scalajs" + +// E2E test configuration - reads ports from environment variables +const frontendPort = parseInt(process.env.VITE_PORT || '3002', 10) +const backendUrl = process.env.VITE_API_URL || 'http://localhost:8080' + +export default defineConfig({ + plugins: [ + scalaJSPlugin({ + cwd: "..", + projectID: "frontend" + }) + ], + server: { + port: frontendPort, + strictPort: true, + host: '127.0.0.1', + proxy: { + '/api': backendUrl + } + } +}) diff --git a/frontend/vite.config.mjs b/frontend/vite.config.mjs new file mode 100644 index 0000000..accb3ec --- /dev/null +++ b/frontend/vite.config.mjs @@ -0,0 +1,22 @@ +import { defineConfig } from 'vite' +import scalaJSPlugin from "@scala-js/vite-plugin-scalajs" + +export default defineConfig(({ mode }) => ({ + plugins: [ + scalaJSPlugin({ + cwd: "..", + projectID: "frontend" + }) + ], + server: { + port: 3000, + proxy: { + '/api': 'http://localhost:8080' + } + }, + build: { + outDir: 'dist', + emptyOutDir: true, + sourcemap: true + } +})) diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..5ddf64d --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version = 1.12.1 diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 0000000..530f2b5 --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1,5 @@ +addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.18.2") +addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.3.2") +addSbtPlugin("io.spray" % "sbt-revolver" % "0.10.0") +addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.4") +addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.10.4") diff --git a/shared/src/main/scala/ssbudget/shared/api/AuthDto.scala b/shared/src/main/scala/ssbudget/shared/api/AuthDto.scala new file mode 100644 index 0000000..b76067f --- /dev/null +++ b/shared/src/main/scala/ssbudget/shared/api/AuthDto.scala @@ -0,0 +1,99 @@ +package ssbudget.shared.api + +import io.circe.Codec + +import java.time.Instant + +// Auth status response +final case class AuthStatus( + configured: Boolean, + passkeyCount: Int, + loggedIn: Boolean, +) derives Codec.AsObject + +// Password setup request (initial setup only) +final case class SetupRequest(password: String) derives Codec.AsObject + +// Password login request +final case class LoginRequest(password: String) derives Codec.AsObject + +// Passkey info for listing +final case class PasskeyInfo( + credentialId: String, + displayName: Option[String], + createdAt: Instant, + lastUsedAt: Option[Instant], +) derives Codec.AsObject + +// Passkey registration start request +final case class PasskeyRegisterStartRequest(displayName: Option[String]) derives Codec.AsObject + +// Passkey registration options (returned from server) +final case class PasskeyRegistrationOptions( + challenge: String, + rpId: String, + rpName: String, + userId: String, + userName: String, + timeout: Long, + attestation: String, + authenticatorSelection: AuthenticatorSelection, + pubKeyCredParams: List[PubKeyCredParam], +) derives Codec.AsObject + +final case class AuthenticatorSelection( + authenticatorAttachment: Option[String], + residentKey: String, + userVerification: String, +) derives Codec.AsObject + +final case class PubKeyCredParam( + `type`: String, + alg: Int, +) derives Codec.AsObject + +// Passkey registration response (from browser) +final case class PasskeyRegistrationResponse( + id: String, + rawId: String, + response: AttestationResponse, + `type`: String, + clientExtensionResults: Option[Map[String, String]], +) derives Codec.AsObject + +final case class AttestationResponse( + clientDataJSON: String, + attestationObject: String, + transports: Option[List[String]], +) derives Codec.AsObject + +// Passkey authentication options (returned from server) +final case class PasskeyAuthenticationOptions( + challenge: String, + rpId: String, + timeout: Long, + userVerification: String, + allowCredentials: List[AllowCredential], +) derives Codec.AsObject + +final case class AllowCredential( + `type`: String, + id: String, + transports: Option[List[String]], +) derives Codec.AsObject + +// Passkey authentication response (from browser) +final case class PasskeyAuthenticationResponse( + id: String, + rawId: String, + response: AssertionResponse, + `type`: String, + clientExtensionResults: Option[Map[String, String]], +) derives Codec.AsObject + +final case class AssertionResponse( + clientDataJSON: String, + authenticatorData: String, + signature: String, + userHandle: Option[String], +) derives Codec.AsObject diff --git a/shared/src/main/scala/ssbudget/shared/api/AuthEndpoints.scala b/shared/src/main/scala/ssbudget/shared/api/AuthEndpoints.scala new file mode 100644 index 0000000..024a13a --- /dev/null +++ b/shared/src/main/scala/ssbudget/shared/api/AuthEndpoints.scala @@ -0,0 +1,162 @@ +package ssbudget.shared.api + +import sttp.model.headers.CookieValueWithMeta +import sttp.tapir.* +import sttp.tapir.json.circe.* +import ssbudget.shared.api.TapirSchemas.given + +object AuthEndpoints { + + /** Secured endpoint type with optional session cookie. */ + type Secured[I, O] = Endpoint[Option[String], I, String, O, Any] + + /** Public endpoint type (no security input). */ + type Public[I, O] = Endpoint[Unit, I, String, O, Any] + + val SessionCookieName = "ssbudget_session" + + private val baseEndpoint = endpoint.in("api" / "auth") + + // Security input for reading optional session cookie + // Using optional to allow decode to succeed even when cookie is absent, + // so serverSecurityLogic can handle missing cookies (e.g., bypass in testMode) + val sessionCookie: EndpointInput.Auth[Option[String], EndpointInput.AuthType.ApiKey] = + auth.apiKey(cookie[Option[String]](SessionCookieName)) + + // Get current auth status - needs optional session to check if logged in + val status: Secured[Unit, AuthStatus] = + baseEndpoint.get + .securityIn(sessionCookie) + .in("status") + .out(jsonBody[AuthStatus]) + .errorOut(stringBody) + + // Initial password setup (only works when not configured) + // Returns session cookie on success (auto-login) + val setup: Public[SetupRequest, CookieValueWithMeta] = + baseEndpoint.post + .in("setup") + .in(jsonBody[SetupRequest]) + .out(setCookie(SessionCookieName)) + .errorOut(stringBody) + + // Password login - returns session cookie + val login: Public[LoginRequest, CookieValueWithMeta] = + baseEndpoint.post + .in("login") + .in(jsonBody[LoginRequest]) + .out(setCookie(SessionCookieName)) + .errorOut(stringBody) + + // Logout - clears session cookie (takes current session for invalidation) + val logout: Secured[Unit, CookieValueWithMeta] = + baseEndpoint.post + .securityIn(sessionCookie) + .in("logout") + .out(setCookie(SessionCookieName)) + .errorOut(stringBody) + + // Passkey endpoints + + // Start passkey registration (authenticated) + val registerPasskeyStart: Secured[PasskeyRegisterStartRequest, PasskeyRegistrationOptions] = + baseEndpoint.post + .securityIn(sessionCookie) + .in("passkey" / "register" / "start") + .in(jsonBody[PasskeyRegisterStartRequest]) + .out(jsonBody[PasskeyRegistrationOptions]) + .errorOut(stringBody) + + // Finish passkey registration (authenticated) + val registerPasskeyFinish: Secured[PasskeyRegistrationResponse, Unit] = + baseEndpoint.post + .securityIn(sessionCookie) + .in("passkey" / "register" / "finish") + .in(jsonBody[PasskeyRegistrationResponse]) + .errorOut(stringBody) + + // Start passkey authentication (public) + val loginPasskeyStart: Public[Unit, PasskeyAuthenticationOptions] = + baseEndpoint.post + .in("passkey" / "login" / "start") + .out(jsonBody[PasskeyAuthenticationOptions]) + .errorOut(stringBody) + + // Finish passkey authentication - returns session cookie + val loginPasskeyFinish: Public[PasskeyAuthenticationResponse, CookieValueWithMeta] = + baseEndpoint.post + .in("passkey" / "login" / "finish") + .in(jsonBody[PasskeyAuthenticationResponse]) + .out(setCookie(SessionCookieName)) + .errorOut(stringBody) + + // List registered passkeys (authenticated) + val listPasskeys: Secured[Unit, List[PasskeyInfo]] = + baseEndpoint.get + .securityIn(sessionCookie) + .in("passkeys") + .out(jsonBody[List[PasskeyInfo]]) + .errorOut(stringBody) + + // Delete a passkey (authenticated) + val deletePasskey: Secured[String, Unit] = + baseEndpoint.delete + .securityIn(sessionCookie) + .in("passkeys" / path[String]("credentialId")) + .errorOut(stringBody) + + val all: List[AnyEndpoint] = List( + status, + setup, + login, + logout, + registerPasskeyStart, + registerPasskeyFinish, + loginPasskeyStart, + loginPasskeyFinish, + listPasskeys, + deletePasskey, + ) + + /** Client-side endpoint definitions for browser use. + * + * These differ from the server endpoints in two ways: 1. Endpoints that return cookies return Unit instead of CookieValueWithMeta because browsers + * handle Set-Cookie headers automatically, and the header is not accessible to JavaScript for security reasons. 2. Endpoints that require + * authentication don't have securityIn because the browser automatically sends cookies with credentials:include. + */ + object client { + val status: Public[Unit, AuthStatus] = + baseEndpoint.get.in("status").out(jsonBody[AuthStatus]).errorOut(stringBody) + + val setup: Public[SetupRequest, Unit] = + baseEndpoint.post.in("setup").in(jsonBody[SetupRequest]).errorOut(stringBody) + + val login: Public[LoginRequest, Unit] = + baseEndpoint.post.in("login").in(jsonBody[LoginRequest]).errorOut(stringBody) + + val logout: Public[Unit, Unit] = + baseEndpoint.post.in("logout").errorOut(stringBody) + + val loginPasskeyStart: Public[Unit, PasskeyAuthenticationOptions] = + baseEndpoint.post.in("passkey" / "login" / "start").out(jsonBody[PasskeyAuthenticationOptions]).errorOut(stringBody) + + val loginPasskeyFinish: Public[PasskeyAuthenticationResponse, Unit] = + baseEndpoint.post.in("passkey" / "login" / "finish").in(jsonBody[PasskeyAuthenticationResponse]).errorOut(stringBody) + + val listPasskeys: Public[Unit, List[PasskeyInfo]] = + baseEndpoint.get.in("passkeys").out(jsonBody[List[PasskeyInfo]]).errorOut(stringBody) + + val deletePasskey: Public[String, Unit] = + baseEndpoint.delete.in("passkeys" / path[String]("credentialId")).errorOut(stringBody) + + val registerPasskeyStart: Public[PasskeyRegisterStartRequest, PasskeyRegistrationOptions] = + baseEndpoint.post + .in("passkey" / "register" / "start") + .in(jsonBody[PasskeyRegisterStartRequest]) + .out(jsonBody[PasskeyRegistrationOptions]) + .errorOut(stringBody) + + val registerPasskeyFinish: Public[PasskeyRegistrationResponse, Unit] = + baseEndpoint.post.in("passkey" / "register" / "finish").in(jsonBody[PasskeyRegistrationResponse]).errorOut(stringBody) + } +} diff --git a/shared/src/main/scala/ssbudget/shared/api/Dto.scala b/shared/src/main/scala/ssbudget/shared/api/Dto.scala new file mode 100644 index 0000000..74db8c9 --- /dev/null +++ b/shared/src/main/scala/ssbudget/shared/api/Dto.scala @@ -0,0 +1,61 @@ +package ssbudget.shared.api + +import io.circe.Codec +import ssbudget.shared.model.* + +// Request DTOs +final case class CreateAccount(name: String, currency: Currency) derives Codec.AsObject + +final case class CreateBalanceSnapshot(accountId: AccountId, amountCents: Long) derives Codec.AsObject + +final case class CreateBudgetItem( + name: String, + itemType: BudgetItemType, + estimateCents: Long, + currency: Currency, +) derives Codec.AsObject + +final case class UpdateBudgetItem( + name: String, + itemType: BudgetItemType, + estimateCents: Long, + currency: Currency, +) derives Codec.AsObject + +final case class PayBudgetItem(amountCents: Long) derives Codec.AsObject + +final case class CreateSavingsAccount(name: String, currency: Currency, plannedMonthly: Option[Long]) derives Codec.AsObject + +final case class UpdateSavingsAccount(name: String, currency: Currency, plannedMonthly: Option[Long]) derives Codec.AsObject + +final case class UpdateSavingsAccountBalance(newBalance: Long) derives Codec.AsObject + +final case class CreateSavingsTransaction(accountId: SavingsAccountId, amount: Long, note: Option[String]) derives Codec.AsObject + +// Response DTOs +final case class IdResponse(id: String) derives Codec.AsObject + +final case class AccountResponse(account: Account, balance: BalanceSnapshot) derives Codec.AsObject + +final case class SavingsTransactionResponse( + transaction: SavingsTransaction, + updatedAccount: SavingsAccount, +) derives Codec.AsObject + +// Currency settings DTOs +final case class EnableCurrencyRequest(code: String) derives Codec.AsObject + +final case class SetPrimaryCurrencyRequest(code: String) derives Codec.AsObject + +final case class KnownCurrency(code: String, name: String) derives Codec.AsObject + +final case class CurrencySettingsResponse( + currencies: List[CurrencySetting], + availableCurrencies: List[KnownCurrency], +) derives Codec.AsObject + +final case class ExchangeRatesResponse( + rates: Map[String, Double], + baseCurrency: String, + fetchedAt: java.time.Instant, +) derives Codec.AsObject diff --git a/shared/src/main/scala/ssbudget/shared/api/Endpoints.scala b/shared/src/main/scala/ssbudget/shared/api/Endpoints.scala new file mode 100644 index 0000000..87fced0 --- /dev/null +++ b/shared/src/main/scala/ssbudget/shared/api/Endpoints.scala @@ -0,0 +1,414 @@ +package ssbudget.shared.api + +import sttp.tapir.* +import sttp.tapir.json.circe.* +import ssbudget.shared.model.* +import ssbudget.shared.api.TapirCodecs.given +import ssbudget.shared.api.TapirSchemas.given + +object Endpoints { + + /** Secured endpoint type with optional session cookie, string error, and any effect. */ + type Secured[I, O] = Endpoint[Option[String], I, String, O, Any] + + /** Client endpoint type without security input (browser sends cookies automatically). */ + type Client[I, O] = Endpoint[Unit, I, String, O, Any] + + private val baseEndpoint = endpoint.in("api") + + // All data endpoints require authentication + private val secureEndpoint = baseEndpoint.securityIn(AuthEndpoints.sessionCookie) + + object accounts { + val list: Secured[Unit, List[Account]] = + secureEndpoint.get + .in("accounts") + .out(jsonBody[List[Account]]) + .errorOut(stringBody) + + val create: Secured[CreateAccount, AccountResponse] = + secureEndpoint.post + .in("accounts") + .in(jsonBody[CreateAccount]) + .out(jsonBody[AccountResponse]) + .errorOut(stringBody) + + val delete: Secured[AccountId, Unit] = + secureEndpoint.delete + .in("accounts" / path[AccountId]("id")) + .errorOut(stringBody) + } + + object balances { + val listLatest: Secured[Unit, List[BalanceSnapshot]] = + secureEndpoint.get + .in("balance-snapshots" / "latest") + .out(jsonBody[List[BalanceSnapshot]]) + .errorOut(stringBody) + + val create: Secured[CreateBalanceSnapshot, BalanceSnapshot] = + secureEndpoint.post + .in("balance-snapshots") + .in(jsonBody[CreateBalanceSnapshot]) + .out(jsonBody[BalanceSnapshot]) + .errorOut(stringBody) + } + + object budgetItems { + val list: Secured[Unit, List[BudgetItemDefinition]] = + secureEndpoint.get + .in("budget-items") + .out(jsonBody[List[BudgetItemDefinition]]) + .errorOut(stringBody) + + val create: Secured[CreateBudgetItem, BudgetItemDefinition] = + secureEndpoint.post + .in("budget-items") + .in(jsonBody[CreateBudgetItem]) + .out(jsonBody[BudgetItemDefinition]) + .errorOut(stringBody) + + val update: Secured[(ExpenseDefId, UpdateBudgetItem), BudgetItemDefinition] = + secureEndpoint.put + .in("budget-items" / path[ExpenseDefId]("id")) + .in(jsonBody[UpdateBudgetItem]) + .out(jsonBody[BudgetItemDefinition]) + .errorOut(stringBody) + + val delete: Secured[ExpenseDefId, Unit] = + secureEndpoint.delete + .in("budget-items" / path[ExpenseDefId]("id")) + .errorOut(stringBody) + } + + object expenseRecords { + val listCurrent: Secured[Unit, List[ExpenseRecord]] = + secureEndpoint.get + .in("expense-records" / "current") + .out(jsonBody[List[ExpenseRecord]]) + .errorOut(stringBody) + + val pay: Secured[(ExpenseDefId, PayBudgetItem), ExpenseRecord] = + secureEndpoint.post + .in("expense-records" / path[ExpenseDefId]("expenseDefId") / "pay") + .in(jsonBody[PayBudgetItem]) + .out(jsonBody[ExpenseRecord]) + .errorOut(stringBody) + + val unpay: Secured[ExpenseDefId, ExpenseRecord] = + secureEndpoint.post + .in("expense-records" / path[ExpenseDefId]("expenseDefId") / "unpay") + .out(jsonBody[ExpenseRecord]) + .errorOut(stringBody) + } + + object periods { + val list: Secured[Unit, List[Period]] = + secureEndpoint.get + .in("periods") + .out(jsonBody[List[Period]]) + .errorOut(stringBody) + + val startNew: Secured[Unit, Period] = + secureEndpoint.post + .in("periods" / "start") + .out(jsonBody[Period]) + .errorOut(stringBody) + } + + object savingsAccounts { + val list: Secured[Unit, List[SavingsAccount]] = + secureEndpoint.get + .in("savings-accounts") + .out(jsonBody[List[SavingsAccount]]) + .errorOut(stringBody) + + val create: Secured[CreateSavingsAccount, SavingsAccount] = + secureEndpoint.post + .in("savings-accounts") + .in(jsonBody[CreateSavingsAccount]) + .out(jsonBody[SavingsAccount]) + .errorOut(stringBody) + + val update: Secured[(SavingsAccountId, UpdateSavingsAccount), SavingsAccount] = + secureEndpoint.put + .in("savings-accounts" / path[SavingsAccountId]("id")) + .in(jsonBody[UpdateSavingsAccount]) + .out(jsonBody[SavingsAccount]) + .errorOut(stringBody) + + val updateBalance: Secured[(SavingsAccountId, UpdateSavingsAccountBalance), SavingsAccount] = + secureEndpoint.put + .in("savings-accounts" / path[SavingsAccountId]("id") / "balance") + .in(jsonBody[UpdateSavingsAccountBalance]) + .out(jsonBody[SavingsAccount]) + .errorOut(stringBody) + + val delete: Secured[SavingsAccountId, Unit] = + secureEndpoint.delete + .in("savings-accounts" / path[SavingsAccountId]("id")) + .errorOut(stringBody) + } + + object savingsTransactions { + val listCurrent: Secured[Unit, List[SavingsTransaction]] = + secureEndpoint.get + .in("savings-transactions" / "current") + .out(jsonBody[List[SavingsTransaction]]) + .errorOut(stringBody) + + val create: Secured[CreateSavingsTransaction, SavingsTransactionResponse] = + secureEndpoint.post + .in("savings-transactions") + .in(jsonBody[CreateSavingsTransaction]) + .out(jsonBody[SavingsTransactionResponse]) + .errorOut(stringBody) + + val delete: Secured[SavingsTransactionId, SavingsAccount] = + secureEndpoint.delete + .in("savings-transactions" / path[SavingsTransactionId]("id")) + .out(jsonBody[SavingsAccount]) + .errorOut(stringBody) + } + + object exchangeRates { + val getAll: Secured[Unit, List[ExchangeRate]] = + secureEndpoint.get + .in("exchange-rates") + .out(jsonBody[List[ExchangeRate]]) + .errorOut(stringBody) + } + + object currencies { + val getSettings: Secured[Unit, CurrencySettingsResponse] = + secureEndpoint.get + .in("currencies") + .out(jsonBody[CurrencySettingsResponse]) + .errorOut(stringBody) + + val enable: Secured[EnableCurrencyRequest, CurrencySetting] = + secureEndpoint.post + .in("currencies") + .in(jsonBody[EnableCurrencyRequest]) + .out(jsonBody[CurrencySetting]) + .errorOut(stringBody) + + val disable: Secured[String, Unit] = + secureEndpoint.delete + .in("currencies" / path[String]("code")) + .errorOut(stringBody) + + val setPrimary: Secured[SetPrimaryCurrencyRequest, Unit] = + secureEndpoint.post + .in("currencies" / "primary") + .in(jsonBody[SetPrimaryCurrencyRequest]) + .errorOut(stringBody) + + val refreshRates: Secured[Unit, ExchangeRatesResponse] = + secureEndpoint.post + .in("currencies" / "refresh-rates") + .out(jsonBody[ExchangeRatesResponse]) + .errorOut(stringBody) + } + + object test { + // Test reset endpoint - still needs to be protected in non-test mode + val reset: Secured[Unit, Unit] = + secureEndpoint.post + .in("test" / "reset") + .errorOut(stringBody) + } + + object database { + val download: Secured[Unit, (String, Array[Byte])] = + secureEndpoint.get + .in("database" / "export") + .out(header[String]("Content-Disposition")) + .out(byteArrayBody) + .errorOut(stringBody) + + val `import`: Secured[Array[Byte], String] = + secureEndpoint.post + .in("database" / "import") + .in(byteArrayBody) + .out(stringBody) + .errorOut(stringBody) + } + + val all: List[AnyEndpoint] = List( + accounts.list, + accounts.create, + accounts.delete, + balances.listLatest, + balances.create, + budgetItems.list, + budgetItems.create, + budgetItems.update, + budgetItems.delete, + expenseRecords.listCurrent, + expenseRecords.pay, + expenseRecords.unpay, + periods.list, + periods.startNew, + savingsAccounts.list, + savingsAccounts.create, + savingsAccounts.update, + savingsAccounts.updateBalance, + savingsAccounts.delete, + savingsTransactions.listCurrent, + savingsTransactions.create, + savingsTransactions.delete, + exchangeRates.getAll, + currencies.getSettings, + currencies.enable, + currencies.disable, + currencies.setPrimary, + currencies.refreshRates, + test.reset, + ) + + /** Client-side endpoint definitions for browser use. + * + * These don't have securityIn because the browser automatically sends cookies with the request. The server still validates the session cookie via + * Tapir's security input. + */ + object client { + object accounts { + val list: Client[Unit, List[Account]] = + baseEndpoint.get.in("accounts").out(jsonBody[List[Account]]).errorOut(stringBody) + + val create: Client[CreateAccount, AccountResponse] = + baseEndpoint.post.in("accounts").in(jsonBody[CreateAccount]).out(jsonBody[AccountResponse]).errorOut(stringBody) + + val delete: Client[AccountId, Unit] = + baseEndpoint.delete.in("accounts" / path[AccountId]("id")).errorOut(stringBody) + } + + object balances { + val listLatest: Client[Unit, List[BalanceSnapshot]] = + baseEndpoint.get.in("balance-snapshots" / "latest").out(jsonBody[List[BalanceSnapshot]]).errorOut(stringBody) + + val create: Client[CreateBalanceSnapshot, BalanceSnapshot] = + baseEndpoint.post.in("balance-snapshots").in(jsonBody[CreateBalanceSnapshot]).out(jsonBody[BalanceSnapshot]).errorOut(stringBody) + } + + object budgetItems { + val list: Client[Unit, List[BudgetItemDefinition]] = + baseEndpoint.get.in("budget-items").out(jsonBody[List[BudgetItemDefinition]]).errorOut(stringBody) + + val create: Client[CreateBudgetItem, BudgetItemDefinition] = + baseEndpoint.post.in("budget-items").in(jsonBody[CreateBudgetItem]).out(jsonBody[BudgetItemDefinition]).errorOut(stringBody) + + val update: Client[(ExpenseDefId, UpdateBudgetItem), BudgetItemDefinition] = + baseEndpoint.put + .in("budget-items" / path[ExpenseDefId]("id")) + .in(jsonBody[UpdateBudgetItem]) + .out(jsonBody[BudgetItemDefinition]) + .errorOut(stringBody) + + val delete: Client[ExpenseDefId, Unit] = + baseEndpoint.delete.in("budget-items" / path[ExpenseDefId]("id")).errorOut(stringBody) + } + + object expenseRecords { + val listCurrent: Client[Unit, List[ExpenseRecord]] = + baseEndpoint.get.in("expense-records" / "current").out(jsonBody[List[ExpenseRecord]]).errorOut(stringBody) + + val pay: Client[(ExpenseDefId, PayBudgetItem), ExpenseRecord] = + baseEndpoint.post + .in("expense-records" / path[ExpenseDefId]("expenseDefId") / "pay") + .in(jsonBody[PayBudgetItem]) + .out(jsonBody[ExpenseRecord]) + .errorOut(stringBody) + + val unpay: Client[ExpenseDefId, ExpenseRecord] = + baseEndpoint.post.in("expense-records" / path[ExpenseDefId]("expenseDefId") / "unpay").out(jsonBody[ExpenseRecord]).errorOut(stringBody) + } + + object periods { + val list: Client[Unit, List[Period]] = + baseEndpoint.get.in("periods").out(jsonBody[List[Period]]).errorOut(stringBody) + + val startNew: Client[Unit, Period] = + baseEndpoint.post.in("periods" / "start").out(jsonBody[Period]).errorOut(stringBody) + } + + object savingsAccounts { + val list: Client[Unit, List[SavingsAccount]] = + baseEndpoint.get.in("savings-accounts").out(jsonBody[List[SavingsAccount]]).errorOut(stringBody) + + val create: Client[CreateSavingsAccount, SavingsAccount] = + baseEndpoint.post.in("savings-accounts").in(jsonBody[CreateSavingsAccount]).out(jsonBody[SavingsAccount]).errorOut(stringBody) + + val update: Client[(SavingsAccountId, UpdateSavingsAccount), SavingsAccount] = + baseEndpoint.put + .in("savings-accounts" / path[SavingsAccountId]("id")) + .in(jsonBody[UpdateSavingsAccount]) + .out(jsonBody[SavingsAccount]) + .errorOut(stringBody) + + val updateBalance: Client[(SavingsAccountId, UpdateSavingsAccountBalance), SavingsAccount] = + baseEndpoint.put + .in("savings-accounts" / path[SavingsAccountId]("id") / "balance") + .in(jsonBody[UpdateSavingsAccountBalance]) + .out(jsonBody[SavingsAccount]) + .errorOut(stringBody) + + val delete: Client[SavingsAccountId, Unit] = + baseEndpoint.delete.in("savings-accounts" / path[SavingsAccountId]("id")).errorOut(stringBody) + } + + object savingsTransactions { + val listCurrent: Client[Unit, List[SavingsTransaction]] = + baseEndpoint.get.in("savings-transactions" / "current").out(jsonBody[List[SavingsTransaction]]).errorOut(stringBody) + + val create: Client[CreateSavingsTransaction, SavingsTransactionResponse] = + baseEndpoint.post + .in("savings-transactions") + .in(jsonBody[CreateSavingsTransaction]) + .out(jsonBody[SavingsTransactionResponse]) + .errorOut(stringBody) + + val delete: Client[SavingsTransactionId, SavingsAccount] = + baseEndpoint.delete.in("savings-transactions" / path[SavingsTransactionId]("id")).out(jsonBody[SavingsAccount]).errorOut(stringBody) + } + + object exchangeRates { + val getAll: Client[Unit, List[ExchangeRate]] = + baseEndpoint.get.in("exchange-rates").out(jsonBody[List[ExchangeRate]]).errorOut(stringBody) + } + + object currencies { + val getSettings: Client[Unit, CurrencySettingsResponse] = + baseEndpoint.get.in("currencies").out(jsonBody[CurrencySettingsResponse]).errorOut(stringBody) + + val enable: Client[EnableCurrencyRequest, CurrencySetting] = + baseEndpoint.post.in("currencies").in(jsonBody[EnableCurrencyRequest]).out(jsonBody[CurrencySetting]).errorOut(stringBody) + + val disable: Client[String, Unit] = + baseEndpoint.delete.in("currencies" / path[String]("code")).errorOut(stringBody) + + val setPrimary: Client[SetPrimaryCurrencyRequest, Unit] = + baseEndpoint.post.in("currencies" / "primary").in(jsonBody[SetPrimaryCurrencyRequest]).errorOut(stringBody) + + val refreshRates: Client[Unit, ExchangeRatesResponse] = + baseEndpoint.post.in("currencies" / "refresh-rates").out(jsonBody[ExchangeRatesResponse]).errorOut(stringBody) + } + + object database { + val download: Client[Unit, (String, Array[Byte])] = + baseEndpoint.get + .in("database" / "export") + .out(header[String]("Content-Disposition")) + .out(byteArrayBody) + .errorOut(stringBody) + + val `import`: Client[Array[Byte], String] = + baseEndpoint.post + .in("database" / "import") + .in(byteArrayBody) + .out(stringBody) + .errorOut(stringBody) + } + } +} diff --git a/shared/src/main/scala/ssbudget/shared/api/HealthEndpoint.scala b/shared/src/main/scala/ssbudget/shared/api/HealthEndpoint.scala new file mode 100644 index 0000000..f53c1bd --- /dev/null +++ b/shared/src/main/scala/ssbudget/shared/api/HealthEndpoint.scala @@ -0,0 +1,9 @@ +package ssbudget.shared.api + +import sttp.tapir.* + +object HealthEndpoint { + val health: Endpoint[Unit, Unit, Unit, String, Any] = endpoint.get + .in("api" / "health") + .out(stringBody) +} diff --git a/shared/src/main/scala/ssbudget/shared/api/TapirCodecs.scala b/shared/src/main/scala/ssbudget/shared/api/TapirCodecs.scala new file mode 100644 index 0000000..8ea4a45 --- /dev/null +++ b/shared/src/main/scala/ssbudget/shared/api/TapirCodecs.scala @@ -0,0 +1,19 @@ +package ssbudget.shared.api + +import sttp.tapir.{Codec, CodecFormat, DecodeResult} +import ssbudget.shared.model.* + +/** Tapir path codecs for ID types */ +object TapirCodecs { + + private def stringIdCodec[T](apply: String => T, unapply: T => String): Codec[String, T, CodecFormat.TextPlain] = + Codec.string.map(apply)(unapply) + + given Codec[String, AccountId, CodecFormat.TextPlain] = stringIdCodec(AccountId.apply, _.value) + given Codec[String, ExpenseDefId, CodecFormat.TextPlain] = stringIdCodec(ExpenseDefId.apply, _.value) + given Codec[String, PeriodId, CodecFormat.TextPlain] = stringIdCodec(PeriodId.apply, _.value) + given Codec[String, BalanceSnapshotId, CodecFormat.TextPlain] = stringIdCodec(BalanceSnapshotId.apply, _.value) + given Codec[String, ExpenseRecordId, CodecFormat.TextPlain] = stringIdCodec(ExpenseRecordId.apply, _.value) + given Codec[String, SavingsAccountId, CodecFormat.TextPlain] = stringIdCodec(SavingsAccountId.apply, _.value) + given Codec[String, SavingsTransactionId, CodecFormat.TextPlain] = stringIdCodec(SavingsTransactionId.apply, _.value) +} diff --git a/shared/src/main/scala/ssbudget/shared/api/TapirSchemas.scala b/shared/src/main/scala/ssbudget/shared/api/TapirSchemas.scala new file mode 100644 index 0000000..13235fc --- /dev/null +++ b/shared/src/main/scala/ssbudget/shared/api/TapirSchemas.scala @@ -0,0 +1,70 @@ +package ssbudget.shared.api + +import sttp.tapir.Schema +import ssbudget.shared.model.* + +/** Tapir Schema definitions for all model types */ +object TapirSchemas { + // ID types - derive as string + given Schema[AccountId] = Schema.string.map[AccountId]((s: String) => Some(AccountId(s)))(_.value) + given Schema[ExpenseDefId] = Schema.string.map[ExpenseDefId]((s: String) => Some(ExpenseDefId(s)))(_.value) + given Schema[PeriodId] = Schema.string.map[PeriodId]((s: String) => Some(PeriodId(s)))(_.value) + given Schema[BalanceSnapshotId] = Schema.string.map[BalanceSnapshotId]((s: String) => Some(BalanceSnapshotId(s)))(_.value) + given Schema[ExpenseRecordId] = Schema.string.map[ExpenseRecordId]((s: String) => Some(ExpenseRecordId(s)))(_.value) + given Schema[SavingsAccountId] = Schema.string.map[SavingsAccountId]((s: String) => Some(SavingsAccountId(s)))(_.value) + given Schema[SavingsTransactionId] = Schema.string.map[SavingsTransactionId]((s: String) => Some(SavingsTransactionId(s)))(_.value) + + // Enums and value types + given Schema[Currency] = Schema.string.map[Currency]((s: String) => Some(Currency(s)))(_.code) + given Schema[BudgetItemType] = Schema.derivedEnumeration[BudgetItemType].defaultStringBased + given Schema[EstimateMode] = Schema.derivedEnumeration[EstimateMode].defaultStringBased + + // Model types + given Schema[Account] = Schema.derived[Account] + given Schema[BalanceSnapshot] = Schema.derived[BalanceSnapshot] + given Schema[BudgetItemDefinition] = Schema.derived[BudgetItemDefinition] + given Schema[ExpenseRecord] = Schema.derived[ExpenseRecord] + given Schema[Period] = Schema.derived[Period] + given Schema[SavingsAccount] = Schema.derived[SavingsAccount] + given Schema[SavingsTransaction] = Schema.derived[SavingsTransaction] + given Schema[ExchangeRate] = Schema.derived[ExchangeRate] + given Schema[Money] = Schema.derived[Money] + + // DTO types + given Schema[CreateAccount] = Schema.derived[CreateAccount] + given Schema[CreateBalanceSnapshot] = Schema.derived[CreateBalanceSnapshot] + given Schema[CreateBudgetItem] = Schema.derived[CreateBudgetItem] + given Schema[UpdateBudgetItem] = Schema.derived[UpdateBudgetItem] + given Schema[PayBudgetItem] = Schema.derived[PayBudgetItem] + given Schema[CreateSavingsAccount] = Schema.derived[CreateSavingsAccount] + given Schema[UpdateSavingsAccount] = Schema.derived[UpdateSavingsAccount] + given Schema[UpdateSavingsAccountBalance] = Schema.derived[UpdateSavingsAccountBalance] + given Schema[CreateSavingsTransaction] = Schema.derived[CreateSavingsTransaction] + given Schema[IdResponse] = Schema.derived[IdResponse] + given Schema[AccountResponse] = Schema.derived[AccountResponse] + given Schema[SavingsTransactionResponse] = Schema.derived[SavingsTransactionResponse] + + // Currency settings DTOs + given Schema[CurrencySetting] = Schema.derived[CurrencySetting] + given Schema[EnableCurrencyRequest] = Schema.derived[EnableCurrencyRequest] + given Schema[SetPrimaryCurrencyRequest] = Schema.derived[SetPrimaryCurrencyRequest] + given Schema[KnownCurrency] = Schema.derived[KnownCurrency] + given Schema[CurrencySettingsResponse] = Schema.derived[CurrencySettingsResponse] + given Schema[ExchangeRatesResponse] = Schema.derived[ExchangeRatesResponse] + + // Auth DTO types + given Schema[AuthStatus] = Schema.derived[AuthStatus] + given Schema[SetupRequest] = Schema.derived[SetupRequest] + given Schema[LoginRequest] = Schema.derived[LoginRequest] + given Schema[PasskeyInfo] = Schema.derived[PasskeyInfo] + given Schema[PasskeyRegisterStartRequest] = Schema.derived[PasskeyRegisterStartRequest] + given Schema[PasskeyRegistrationOptions] = Schema.derived[PasskeyRegistrationOptions] + given Schema[AuthenticatorSelection] = Schema.derived[AuthenticatorSelection] + given Schema[PubKeyCredParam] = Schema.derived[PubKeyCredParam] + given Schema[PasskeyRegistrationResponse] = Schema.derived[PasskeyRegistrationResponse] + given Schema[AttestationResponse] = Schema.derived[AttestationResponse] + given Schema[PasskeyAuthenticationOptions] = Schema.derived[PasskeyAuthenticationOptions] + given Schema[AllowCredential] = Schema.derived[AllowCredential] + given Schema[PasskeyAuthenticationResponse] = Schema.derived[PasskeyAuthenticationResponse] + given Schema[AssertionResponse] = Schema.derived[AssertionResponse] +} diff --git a/shared/src/main/scala/ssbudget/shared/json/EnumCodec.scala b/shared/src/main/scala/ssbudget/shared/json/EnumCodec.scala new file mode 100644 index 0000000..1ee6845 --- /dev/null +++ b/shared/src/main/scala/ssbudget/shared/json/EnumCodec.scala @@ -0,0 +1,23 @@ +package ssbudget.shared.json + +import io.circe.{Codec, Decoder, Encoder} + +object EnumCodec { + + /** Creates a circe Codec for an enum type. + * + * @param values + * All enum values (e.g., `Currency.values`) + * @param toName + * Function to convert enum value to its JSON string representation + * @param typeName + * Name used in error messages (defaults to "value") + */ + def apply[E](values: Array[E], toName: E => String, typeName: String = "value"): Codec[E] = { + val nameToValue = values.map(v => toName(v) -> v).toMap + Codec.from( + Decoder.decodeString.emap(s => nameToValue.get(s).toRight(s"Unknown $typeName: $s")), + Encoder.encodeString.contramap(toName), + ) + } +} diff --git a/shared/src/main/scala/ssbudget/shared/json/StringId.scala b/shared/src/main/scala/ssbudget/shared/json/StringId.scala new file mode 100644 index 0000000..59d5d4c --- /dev/null +++ b/shared/src/main/scala/ssbudget/shared/json/StringId.scala @@ -0,0 +1,21 @@ +package ssbudget.shared.json + +import io.circe.{Codec, Decoder, Encoder} + +/** Typeclass for String-based ID types (AnyVal wrappers). + * + * Usage: + * {{{ + * final case class AccountId(value: String) extends AnyVal + * object AccountId extends StringId[AccountId] + * }}} + */ +trait StringId[T <: Product] { + def apply(value: String): T + def value(t: T): String = t.productElement(0).asInstanceOf[String] + + given Codec[T] = Codec.from( + Decoder.decodeString.map(apply), + Encoder.encodeString.contramap(value), + ) +} diff --git a/shared/src/main/scala/ssbudget/shared/model/Account.scala b/shared/src/main/scala/ssbudget/shared/model/Account.scala new file mode 100644 index 0000000..9cca344 --- /dev/null +++ b/shared/src/main/scala/ssbudget/shared/model/Account.scala @@ -0,0 +1,14 @@ +package ssbudget.shared.model + +import io.circe.Codec +import ssbudget.shared.json.StringId + +final case class AccountId(value: String) extends AnyVal + +object AccountId extends StringId[AccountId] + +final case class Account( + id: AccountId, + name: String, + currency: Currency, +) derives Codec.AsObject diff --git a/shared/src/main/scala/ssbudget/shared/model/BalanceSnapshot.scala b/shared/src/main/scala/ssbudget/shared/model/BalanceSnapshot.scala new file mode 100644 index 0000000..85c1122 --- /dev/null +++ b/shared/src/main/scala/ssbudget/shared/model/BalanceSnapshot.scala @@ -0,0 +1,19 @@ +package ssbudget.shared.model + +import io.circe.Codec +import ssbudget.shared.json.StringId + +import java.time.Instant + +final case class BalanceSnapshotId(value: String) extends AnyVal +object BalanceSnapshotId extends StringId[BalanceSnapshotId] + +final case class BalanceSnapshot( + id: BalanceSnapshotId, + accountId: AccountId, + amount: Long, // in cents + currency: Currency, + recordedAt: Instant, +) derives Codec.AsObject { + def balance: Money = Money(amount, currency) +} diff --git a/shared/src/main/scala/ssbudget/shared/model/CurrencySetting.scala b/shared/src/main/scala/ssbudget/shared/model/CurrencySetting.scala new file mode 100644 index 0000000..a0fb026 --- /dev/null +++ b/shared/src/main/scala/ssbudget/shared/model/CurrencySetting.scala @@ -0,0 +1,11 @@ +package ssbudget.shared.model + +import io.circe.Codec +import java.time.Instant + +final case class CurrencySetting( + code: Currency, + name: String, + isPrimary: Boolean, + enabledAt: Instant, +) derives Codec.AsObject diff --git a/shared/src/main/scala/ssbudget/shared/model/ExchangeRate.scala b/shared/src/main/scala/ssbudget/shared/model/ExchangeRate.scala new file mode 100644 index 0000000..0b20d3c --- /dev/null +++ b/shared/src/main/scala/ssbudget/shared/model/ExchangeRate.scala @@ -0,0 +1,29 @@ +package ssbudget.shared.model + +import io.circe.Codec +import java.time.Instant + +final case class ExchangeRate( + fromCurrency: Currency, + toCurrency: Currency, + rate: Long, // rate * 10000 for precision (e.g., 4.5 PLN/EUR = 45000) + fetchedAt: Instant, +) derives Codec.AsObject { + def rateAsDouble: Double = rate / 10000.0 + + def convert(money: Money): Money = { + require(money.currency == fromCurrency, s"Expected $fromCurrency but got ${money.currency}") + Money((money.amountCents * rate / 10000).toLong, toCurrency) + } +} + +object ExchangeRate { + def fromDouble( + from: Currency, + to: Currency, + rate: Double, + fetchedAt: Instant, + ): ExchangeRate = { + ExchangeRate(from, to, (rate * 10000).toLong, fetchedAt) + } +} diff --git a/shared/src/main/scala/ssbudget/shared/model/ExpenseDefinition.scala b/shared/src/main/scala/ssbudget/shared/model/ExpenseDefinition.scala new file mode 100644 index 0000000..96d5e43 --- /dev/null +++ b/shared/src/main/scala/ssbudget/shared/model/ExpenseDefinition.scala @@ -0,0 +1,58 @@ +package ssbudget.shared.model + +import io.circe.Codec +import ssbudget.shared.json.{EnumCodec, StringId} + +final case class ExpenseDefId(value: String) extends AnyVal +object ExpenseDefId extends StringId[ExpenseDefId] + +enum BudgetItemType { + case PlannedExpense, EstimatedExpense, PlannedIncome +} + +object BudgetItemType { + given Codec[BudgetItemType] = EnumCodec( + BudgetItemType.values, + { + case PlannedExpense => "planned_expense" + case EstimatedExpense => "estimated_expense" + case PlannedIncome => "planned_income" + }, + "budget item type", + ) +} + +// Keep ExpenseType as alias for compatibility during transition +type ExpenseType = BudgetItemType +val ExpenseType = BudgetItemType + +enum EstimateMode { + case Fixed, LastMonth, Average +} + +object EstimateMode { + given Codec[EstimateMode] = EnumCodec( + EstimateMode.values, + { + case Fixed => "fixed" + case LastMonth => "last_month" + case Average => "average" + }, + "estimate mode", + ) +} + +final case class BudgetItemDefinition( + id: ExpenseDefId, + name: String, + itemType: BudgetItemType, + estimateMode: EstimateMode, + fixedEstimate: Option[Long], // in cents, only for Fixed mode + currency: Currency, +) derives Codec.AsObject { + def estimateMoney: Option[Money] = fixedEstimate.map(cents => Money(cents, currency)) +} + +// Keep ExpenseDefinition as alias for compatibility +type ExpenseDefinition = BudgetItemDefinition +val ExpenseDefinition = BudgetItemDefinition diff --git a/shared/src/main/scala/ssbudget/shared/model/ExpenseRecord.scala b/shared/src/main/scala/ssbudget/shared/model/ExpenseRecord.scala new file mode 100644 index 0000000..551397c --- /dev/null +++ b/shared/src/main/scala/ssbudget/shared/model/ExpenseRecord.scala @@ -0,0 +1,17 @@ +package ssbudget.shared.model + +import io.circe.Codec +import ssbudget.shared.json.StringId + +import java.time.Instant + +final case class ExpenseRecordId(value: String) extends AnyVal +object ExpenseRecordId extends StringId[ExpenseRecordId] + +final case class ExpenseRecord( + id: ExpenseRecordId, + periodId: PeriodId, + expenseDefId: ExpenseDefId, + paidAmount: Option[Long], // in cents, None until paid + paidAt: Option[Instant], // None until paid +) derives Codec.AsObject diff --git a/shared/src/main/scala/ssbudget/shared/model/Money.scala b/shared/src/main/scala/ssbudget/shared/model/Money.scala new file mode 100644 index 0000000..5e67b75 --- /dev/null +++ b/shared/src/main/scala/ssbudget/shared/model/Money.scala @@ -0,0 +1,92 @@ +package ssbudget.shared.model + +import io.circe.{Codec, Decoder, Encoder} + +final case class Currency(code: String) extends AnyVal + +object Currency { + // Common currency constants for convenience + val PLN: Currency = Currency("PLN") + val EUR: Currency = Currency("EUR") + val USD: Currency = Currency("USD") + val GBP: Currency = Currency("GBP") + + // Encode as plain string, not object + given Encoder[Currency] = Encoder.encodeString.contramap(_.code) + given Decoder[Currency] = Decoder.decodeString.map(Currency.apply) + + // List of known ISO 4217 currency codes with names (for UI autocomplete) + val knownCurrencies: List[(String, String)] = List( + ("AUD", "Australian Dollar"), + ("BGN", "Bulgarian Lev"), + ("BRL", "Brazilian Real"), + ("CAD", "Canadian Dollar"), + ("CHF", "Swiss Franc"), + ("CNY", "Chinese Yuan"), + ("CZK", "Czech Koruna"), + ("DKK", "Danish Krone"), + ("EUR", "Euro"), + ("GBP", "British Pound"), + ("HKD", "Hong Kong Dollar"), + ("HRK", "Croatian Kuna"), + ("HUF", "Hungarian Forint"), + ("IDR", "Indonesian Rupiah"), + ("ILS", "Israeli Shekel"), + ("INR", "Indian Rupee"), + ("ISK", "Icelandic Krona"), + ("JPY", "Japanese Yen"), + ("KRW", "South Korean Won"), + ("MXN", "Mexican Peso"), + ("MYR", "Malaysian Ringgit"), + ("NOK", "Norwegian Krone"), + ("NZD", "New Zealand Dollar"), + ("PHP", "Philippine Peso"), + ("PLN", "Polish Zloty"), + ("RON", "Romanian Leu"), + ("SEK", "Swedish Krona"), + ("SGD", "Singapore Dollar"), + ("THB", "Thai Baht"), + ("TRY", "Turkish Lira"), + ("USD", "US Dollar"), + ("ZAR", "South African Rand"), + ) + + def isKnown(code: String): Boolean = knownCurrencies.exists(_._1 == code) + + def nameFor(code: String): Option[String] = knownCurrencies.find(_._1 == code).map(_._2) +} + +final case class Money(amountCents: Long, currency: Currency) derives Codec.AsObject { + def toDouble: Double = amountCents / 100.0 + + def formatted: String = s"${amountCents / 100.0} ${currency.code}" + + def +(other: Money): Money = { + require(currency == other.currency, s"Cannot add $currency and ${other.currency}") + Money(amountCents + other.amountCents, currency) + } + + def -(other: Money): Money = { + require(currency == other.currency, s"Cannot subtract $currency and ${other.currency}") + Money(amountCents - other.amountCents, currency) + } + + def *(factor: Double): Money = { + Money((amountCents * factor).toLong, currency) + } + + def /(divisor: Double): Money = { + Money((amountCents / divisor).toLong, currency) + } +} + +object Money { + def fromDouble(amount: Double, currency: Currency): Money = { + Money((amount * 100).toLong, currency) + } + + def fromCents(cents: Long, currency: Currency): Money = Money(cents, currency) + + def zero(currency: Currency): Money = Money(0, currency) + +} diff --git a/shared/src/main/scala/ssbudget/shared/model/Period.scala b/shared/src/main/scala/ssbudget/shared/model/Period.scala new file mode 100644 index 0000000..00d1f0d --- /dev/null +++ b/shared/src/main/scala/ssbudget/shared/model/Period.scala @@ -0,0 +1,15 @@ +package ssbudget.shared.model + +import io.circe.Codec +import ssbudget.shared.json.StringId + +import java.time.Instant + +final case class PeriodId(value: String) extends AnyVal +object PeriodId extends StringId[PeriodId] + +final case class Period( + id: PeriodId, + startDate: Instant, + endDate: Option[Instant], // None until period is closed +) derives Codec.AsObject diff --git a/shared/src/main/scala/ssbudget/shared/model/SavingsAccount.scala b/shared/src/main/scala/ssbudget/shared/model/SavingsAccount.scala new file mode 100644 index 0000000..846182e --- /dev/null +++ b/shared/src/main/scala/ssbudget/shared/model/SavingsAccount.scala @@ -0,0 +1,18 @@ +package ssbudget.shared.model + +import io.circe.Codec +import ssbudget.shared.json.StringId + +final case class SavingsAccountId(value: String) extends AnyVal + +object SavingsAccountId extends StringId[SavingsAccountId] + +final case class SavingsAccount( + id: SavingsAccountId, + name: String, + currency: Currency, + currentBalance: Long, // in cents, editable directly + plannedMonthly: Option[Long], // optional monthly target in cents +) derives Codec.AsObject { + def balance: Money = Money(currentBalance, currency) +} diff --git a/shared/src/main/scala/ssbudget/shared/model/SavingsTransaction.scala b/shared/src/main/scala/ssbudget/shared/model/SavingsTransaction.scala new file mode 100644 index 0000000..75b2b09 --- /dev/null +++ b/shared/src/main/scala/ssbudget/shared/model/SavingsTransaction.scala @@ -0,0 +1,19 @@ +package ssbudget.shared.model + +import io.circe.Codec +import ssbudget.shared.json.StringId + +import java.time.Instant + +final case class SavingsTransactionId(value: String) extends AnyVal + +object SavingsTransactionId extends StringId[SavingsTransactionId] + +final case class SavingsTransaction( + id: SavingsTransactionId, + accountId: SavingsAccountId, + periodId: PeriodId, + amount: Long, // positive = inflow, negative = outflow + note: Option[String], // optional context + createdAt: Instant, +) derives Codec.AsObject