diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f16e433cf..a4167ea0e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,7 +4,8 @@ "service": "app", "runServices": [ "db", - "redis" + "redis", + "selenium" ], "workspaceFolder": "/workspace", "containerEnv": { diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 3a48dc7a8..f622e3686 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -10,6 +10,7 @@ x-rails-env: &rails_env POSTGRES_PASSWORD: postgres BUNDLE_PATH: /bundle REDIS_URL: redis://redis:6379/1 + SELENIUM_REMOTE_URL: http://selenium:4444 services: app: @@ -28,6 +29,7 @@ services: depends_on: - db - redis + - selenium worker: build: @@ -59,6 +61,14 @@ services: environment: <<: *db_env + selenium: + image: selenium/standalone-chromium:latest + ports: + - "4444:4444" + - "7900:7900" + shm_size: 2gb + restart: unless-stopped + volumes: postgres-data: redis-data: diff --git a/.env.example b/.env.example index 548bd1971..f5e98e7a4 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,21 @@ OPENAI_ACCESS_TOKEN= OPENAI_MODEL= OPENAI_URI_BASE= +# Optional: External AI Assistant — delegates chat to a remote AI agent +# instead of calling LLMs directly. The agent calls back to Sure's /mcp endpoint. +# See docs/hosting/ai.md for full details. +# ASSISTANT_TYPE=external +# EXTERNAL_ASSISTANT_URL=https://your-agent-host/v1/chat/completions +# EXTERNAL_ASSISTANT_TOKEN=your-api-token +# EXTERNAL_ASSISTANT_AGENT_ID=main +# EXTERNAL_ASSISTANT_SESSION_KEY=agent:main:main +# EXTERNAL_ASSISTANT_ALLOWED_EMAILS=user@example.com + +# Optional: MCP server endpoint — enables /mcp for external AI assistants. +# Both values are required. MCP_USER_EMAIL must match an existing user's email. +# MCP_API_TOKEN=your-random-bearer-token +# MCP_USER_EMAIL=user@example.com + # Optional: Langfuse config LANGFUSE_HOST=https://cloud.langfuse.com LANGFUSE_PUBLIC_KEY= @@ -59,6 +74,7 @@ SMTP_PORT=465 SMTP_USERNAME= SMTP_PASSWORD= SMTP_TLS_ENABLED=true +SMTP_TLS_SKIP_VERIFY=false # Address that emails are sent from EMAIL_SENDER= diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index bd4be1e07..1c098fd39 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -16,7 +16,7 @@ Purpose: provide short, actionable guidance so Copilot suggestions match project ### Testing - `bin/rails test` - Run all tests - `bin/rails test:db` - Run tests with database reset -- `bin/rails test:system` - Run system tests only (use sparingly - they take longer) +- `DISABLE_PARALLELIZATION=true bin/rails test:system` - Run system tests only (use sparingly - they take longer) - `bin/rails test test/models/account_test.rb` - Run specific test file - `bin/rails test test/models/account_test.rb:42` - Run specific test at line @@ -37,7 +37,7 @@ Purpose: provide short, actionable guidance so Copilot suggestions match project - `bin/setup` - Initial project setup (installs dependencies, prepares database) ## Pre-PR workflow (run locally before opening PR) -- Tests: bin/rails test (all), bin/rails test:system (when applicable) +- Tests: bin/rails test (all), DISABLE_PARALLELIZATION=true bin/rails test:system (when applicable) - Linters: bin/rubocop -f github -a; bundle exec erb_lint ./app/**/*.erb -a - Security: bin/brakeman --no-pager diff --git a/.github/workflows/pipelock.yml b/.github/workflows/pipelock.yml index 741a344ff..deef6acd9 100644 --- a/.github/workflows/pipelock.yml +++ b/.github/workflows/pipelock.yml @@ -17,8 +17,15 @@ jobs: persist-credentials: false - name: Pipelock Scan - uses: luckyPipewrench/pipelock@v1 + uses: luckyPipewrench/pipelock@v2 with: scan-diff: 'true' fail-on-findings: 'true' test-vectors: 'false' + exclude-paths: | + .env.example + compose.example.yml + compose.example.ai.yml + config/locales/views/reports/ + docs/hosting/ai.md + app/models/provider/binance.rb diff --git a/.github/workflows/update-docs.yml b/.github/workflows/update-docs.yml index a218de355..720651b87 100644 --- a/.github/workflows/update-docs.yml +++ b/.github/workflows/update-docs.yml @@ -7,6 +7,8 @@ on: jobs: update-docs: + if: github.repository == 'we-promise/sure' + permissions: {} runs-on: ubuntu-latest steps: - uses: actions/github-script@v8 diff --git a/.gitignore b/.gitignore index d2baf4c85..731dd2f9e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ # or operating system, you probably want to add a global ignore instead: # git config --global core.excludesfile '~/.gitignore_global' +# Git Worktrees +.worktrees/ + # Ignore bundler config. /.bundle /vendor/bundle @@ -73,6 +76,10 @@ compose.yml plaid_test_accounts/ +# Added by Claude +.claude/settings.local.json +docs/superpowers/ + # Added by Claude Task Master # Logs logs @@ -108,7 +115,6 @@ scripts/ .cursor/rules/dev_workflow.mdc .cursor/rules/taskmaster.mdc - # Auto Claude data directory .auto-claude/ @@ -116,6 +122,5 @@ scripts/ .auto-claude-security.json .auto-claude-status .claude_settings.json -.worktrees/ .security-key -logs/security/ +logs/security/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 1e2bcc321..ddd38afd0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,10 +12,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ### Testing - `bin/rails test` - Run all tests - `bin/rails test:db` - Run tests with database reset -- `bin/rails test:system` - Run system tests only (use sparingly - they take longer) +- `DISABLE_PARALLELIZATION=true bin/rails test:system` - Run system tests only (use sparingly - they take longer) - `bin/rails test test/models/account_test.rb` - Run specific test file - `bin/rails test test/models/account_test.rb:42` - Run specific test at line +#### System Tests in the Dev Container +When running inside the Dev Container, the `SELENIUM_REMOTE_URL` environment variable is automatically set to the bundled `selenium/standalone-chromium` service. System tests will connect to that remote browser — no local Chrome installation is required. + +```bash +DISABLE_PARALLELIZATION=true bin/rails test:system +``` + +To watch the browser live, open `http://localhost:7900` or `http://localhost:4444` in your host browser (password: `secret`). + ### Linting & Formatting - `bin/rubocop` - Run Ruby linter - `npm run lint` - Check JavaScript/TypeScript code @@ -38,7 +47,7 @@ ALWAYS run these commands before opening a pull request: 1. **Tests** (Required): - `bin/rails test` - Run all tests (always required) - - `bin/rails test:system` - Run system tests (only when applicable, they take longer) + - `DISABLE_PARALLELIZATION=true bin/rails test:system` - Run system tests (only when applicable, they take longer) 2. **Linting** (Required): - `bin/rubocop -f github -a` - Ruby linting with auto-correct diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e032cd731..a97e19f33 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,6 +24,9 @@ In general, _full features_ that get us closer to [our 🔜 Vision](https://gith To get setup for local development, you have two options: 1. [Dev Containers](https://code.visualstudio.com/docs/devcontainers/containers) with VSCode (see the `.devcontainer` folder) + - A `selenium/standalone-chrome` service is included in the Dev Container setup, so **system tests work out of the box** — no local Chrome required. + - Run system tests: `DISABLE_PARALLELIZATION=true bin/rails test:system` + - Watch the browser live at `http://localhost:7900` or `http://localhost:4444` (password: `secret`) 2. Local Development - [Mac Setup Guide](https://github.com/we-promise/sure/wiki/Mac-Dev-Setup-Guide) - [Linux Setup Guide](https://github.com/we-promise/sure/wiki/Linux-Dev-Setup-Guide) @@ -40,3 +43,12 @@ To get setup for local development, you have two options: 7. Before requesting a review, please make sure that all [Github Checks](https://docs.github.com/en/rest/checks?apiVersion=2022-11-28) have passed and your branch is up-to-date with the `main` branch. After doing so, request a review and wait for a maintainer's approval. All PRs should target the `main` branch. + +### Automated Security Scanning + +Every pull request to the `main` branch automatically runs a Pipelock security scan. This scan analyzes your PR diff for: + +- Leaked secrets (API keys, tokens, credentials) +- Agent security risks (misconfigurations, exposed credentials, missing controls) + +The scan runs as part of the CI pipeline and typically completes in ~30 seconds. If security issues are found, the CI check will fail. You don't need to configure anything—the security scanning is automatic and zero-configuration. diff --git a/Gemfile b/Gemfile index 95c92d6d5..de23ef7d8 100644 --- a/Gemfile +++ b/Gemfile @@ -31,6 +31,7 @@ gem "lookbook", "2.3.11" gem "hotwire_combobox" # Background Jobs +gem "connection_pool", "~> 2.5" # pin to 2.x; 3.0 breaks sidekiq 8.x gem "sidekiq" gem "sidekiq-cron" gem "sidekiq-unique-jobs" @@ -68,6 +69,7 @@ gem "faraday-multipart" gem "inline_svg" gem "octokit" gem "pagy" +gem "rails-i18n" gem "rails-settings-cached" gem "tzinfo-data", platforms: %i[windows jruby] gem "csv" @@ -124,6 +126,13 @@ group :development do gem "foreman" end +group :development, :test do + gem "rspec-rails" + gem "rswag-api" + gem "rswag-specs" + gem "rswag-ui" +end + group :test do gem "capybara" gem "selenium-webdriver" @@ -132,8 +141,4 @@ group :test do gem "webmock" gem "climate_control" gem "simplecov", require: false - gem "rspec-rails" - gem "rswag-api" - gem "rswag-specs" - gem "rswag-ui" end diff --git a/Gemfile.lock b/Gemfile.lock index 8b6d1b77e..a858755ec 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -4,68 +4,70 @@ GEM Ascii85 (2.0.1) aasm (5.5.1) concurrent-ruby (~> 1.0) - actioncable (7.2.2.2) - actionpack (= 7.2.2.2) - activesupport (= 7.2.2.2) + actioncable (7.2.3.1) + actionpack (= 7.2.3.1) + activesupport (= 7.2.3.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.2.2.2) - actionpack (= 7.2.2.2) - activejob (= 7.2.2.2) - activerecord (= 7.2.2.2) - activestorage (= 7.2.2.2) - activesupport (= 7.2.2.2) + actionmailbox (7.2.3.1) + actionpack (= 7.2.3.1) + activejob (= 7.2.3.1) + activerecord (= 7.2.3.1) + activestorage (= 7.2.3.1) + activesupport (= 7.2.3.1) mail (>= 2.8.0) - actionmailer (7.2.2.2) - actionpack (= 7.2.2.2) - actionview (= 7.2.2.2) - activejob (= 7.2.2.2) - activesupport (= 7.2.2.2) + actionmailer (7.2.3.1) + actionpack (= 7.2.3.1) + actionview (= 7.2.3.1) + activejob (= 7.2.3.1) + activesupport (= 7.2.3.1) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (7.2.2.2) - actionview (= 7.2.2.2) - activesupport (= 7.2.2.2) + actionpack (7.2.3.1) + actionview (= 7.2.3.1) + activesupport (= 7.2.3.1) + cgi nokogiri (>= 1.8.5) racc - rack (>= 2.2.4, < 3.2) + rack (>= 2.2.4, < 3.3) rack-session (>= 1.0.1) rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (7.2.2.2) - actionpack (= 7.2.2.2) - activerecord (= 7.2.2.2) - activestorage (= 7.2.2.2) - activesupport (= 7.2.2.2) + actiontext (7.2.3.1) + actionpack (= 7.2.3.1) + activerecord (= 7.2.3.1) + activestorage (= 7.2.3.1) + activesupport (= 7.2.3.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.2.2.2) - activesupport (= 7.2.2.2) + actionview (7.2.3.1) + activesupport (= 7.2.3.1) builder (~> 3.1) + cgi erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (7.2.2.2) - activesupport (= 7.2.2.2) + activejob (7.2.3.1) + activesupport (= 7.2.3.1) globalid (>= 0.3.6) - activemodel (7.2.2.2) - activesupport (= 7.2.2.2) - activerecord (7.2.2.2) - activemodel (= 7.2.2.2) - activesupport (= 7.2.2.2) + activemodel (7.2.3.1) + activesupport (= 7.2.3.1) + activerecord (7.2.3.1) + activemodel (= 7.2.3.1) + activesupport (= 7.2.3.1) timeout (>= 0.4.0) activerecord-import (2.2.0) activerecord (>= 4.2) - activestorage (7.2.2.2) - actionpack (= 7.2.2.2) - activejob (= 7.2.2.2) - activerecord (= 7.2.2.2) - activesupport (= 7.2.2.2) + activestorage (7.2.3.1) + actionpack (= 7.2.3.1) + activejob (= 7.2.3.1) + activerecord (= 7.2.3.1) + activesupport (= 7.2.3.1) marcel (~> 1.0) - activesupport (7.2.2.2) + activesupport (7.2.3.1) base64 benchmark (>= 0.3) bigdecimal @@ -74,7 +76,7 @@ GEM drb i18n (>= 1.6, < 2) logger (>= 1.4.2) - minitest (>= 5.1) + minitest (>= 5.1, < 6) securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) addressable (2.8.7) @@ -106,8 +108,8 @@ GEM aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) base64 (0.3.0) - bcrypt (3.1.20) - benchmark (0.4.1) + bcrypt (3.1.22) + benchmark (0.5.0) benchmark-ips (2.14.0) better_html (2.1.1) actionview (>= 6.0) @@ -133,12 +135,13 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) + cgi (0.5.1) childprocess (5.1.0) logger (~> 1.5) chunky_png (1.4.0) climate_control (1.2.0) - concurrent-ruby (1.3.5) - connection_pool (2.5.3) + concurrent-ruby (1.3.6) + connection_pool (2.5.5) countries (8.0.3) unaccent (~> 0.3) crack (1.0.0) @@ -226,7 +229,7 @@ GEM get_process_mem (1.0.0) bigdecimal (>= 2.0) ffi (~> 1.0) - globalid (1.2.1) + globalid (1.3.0) activesupport (>= 6.1) hashdiff (1.2.0) hashery (2.1.2) @@ -250,7 +253,7 @@ GEM csv mini_mime (>= 1.0.0) multi_xml (>= 0.5.2) - i18n (1.14.7) + i18n (1.14.8) concurrent-ruby (~> 1.0) i18n-tasks (1.0.15) activesupport (>= 4.0.2) @@ -282,7 +285,7 @@ GEM actionview (>= 5.0.0) activesupport (>= 5.0.0) jmespath (1.6.2) - json (2.18.1) + json (2.19.2) json-jwt (1.16.7) activesupport (>= 4.2) aes_key_wrap @@ -323,7 +326,7 @@ GEM logtail (~> 0.1, >= 0.1.14) logtail-rack (~> 0.1) railties (>= 5.0.0) - loofah (2.24.1) + loofah (2.25.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) lookbook (2.3.11) @@ -345,7 +348,7 @@ GEM net-imap net-pop net-smtp - marcel (1.0.4) + marcel (1.1.0) matrix (0.4.2) memory_profiler (1.1.0) method_source (1.1.0) @@ -354,7 +357,7 @@ GEM benchmark logger mini_mime (1.1.5) - minitest (5.25.5) + minitest (5.27.0) mocha (2.7.1) ruby2_keywords (>= 0.0.5) msgpack (1.8.0) @@ -374,21 +377,21 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.4) - nokogiri (1.19.1-aarch64-linux-gnu) + nokogiri (1.19.2-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.1-aarch64-linux-musl) + nokogiri (1.19.2-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.19.1-arm-linux-gnu) + nokogiri (1.19.2-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.19.1-arm-linux-musl) + nokogiri (1.19.2-arm-linux-musl) racc (~> 1.4) - nokogiri (1.19.1-arm64-darwin) + nokogiri (1.19.2-arm64-darwin) racc (~> 1.4) - nokogiri (1.19.1-x86_64-darwin) + nokogiri (1.19.2-x86_64-darwin) racc (~> 1.4) - nokogiri (1.19.1-x86_64-linux-gnu) + nokogiri (1.19.2-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.1-x86_64-linux-musl) + nokogiri (1.19.2-x86_64-linux-musl) racc (~> 1.4) oauth2 (2.0.18) faraday (>= 0.17.3, < 4.0) @@ -441,7 +444,7 @@ GEM ostruct (0.6.2) pagy (9.3.5) parallel (1.27.0) - parser (3.3.8.0) + parser (3.3.10.2) ast (~> 2.4.1) racc pdf-reader (2.15.1) @@ -478,7 +481,7 @@ GEM activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (3.1.20) + rack (3.2.6) rack-attack (6.7.0) rack (>= 1.0, < 4) rack-cors (3.0.0) @@ -504,26 +507,26 @@ GEM rack (>= 1.3) rackup (2.2.1) rack (>= 3) - rails (7.2.2.2) - actioncable (= 7.2.2.2) - actionmailbox (= 7.2.2.2) - actionmailer (= 7.2.2.2) - actionpack (= 7.2.2.2) - actiontext (= 7.2.2.2) - actionview (= 7.2.2.2) - activejob (= 7.2.2.2) - activemodel (= 7.2.2.2) - activerecord (= 7.2.2.2) - activestorage (= 7.2.2.2) - activesupport (= 7.2.2.2) + rails (7.2.3.1) + actioncable (= 7.2.3.1) + actionmailbox (= 7.2.3.1) + actionmailer (= 7.2.3.1) + actionpack (= 7.2.3.1) + actiontext (= 7.2.3.1) + actionview (= 7.2.3.1) + activejob (= 7.2.3.1) + activemodel (= 7.2.3.1) + activerecord (= 7.2.3.1) + activestorage (= 7.2.3.1) + activesupport (= 7.2.3.1) bundler (>= 1.15.0) - railties (= 7.2.2.2) + railties (= 7.2.3.1) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.2) - loofah (~> 2.21) + rails-html-sanitizer (1.7.0) + loofah (~> 2.25) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) rails-i18n (7.0.10) i18n (>= 0.7, < 2) @@ -531,13 +534,15 @@ GEM rails-settings-cached (2.9.6) activerecord (>= 5.0.0) railties (>= 5.0.0) - railties (7.2.2.2) - actionpack (= 7.2.2.2) - activesupport (= 7.2.2.2) + railties (7.2.3.1) + actionpack (= 7.2.3.1) + activesupport (= 7.2.3.1) + cgi irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.3.0) @@ -621,13 +626,12 @@ GEM rubocop (>= 1.72) rubocop-performance (>= 1.24) rubocop-rails (>= 2.30) - ruby-lsp (0.24.1) + ruby-lsp (0.26.9) language_server-protocol (~> 3.17.0) prism (>= 1.2, < 2.0) rbs (>= 3, < 5) - sorbet-runtime (>= 0.5.10782) - ruby-lsp-rails (0.4.6) - ruby-lsp (>= 0.24.0, < 0.25.0) + ruby-lsp-rails (0.4.8) + ruby-lsp (>= 0.26.0, < 0.27.0) ruby-openai (8.1.0) event_stream_parser (>= 0.3.0, < 2.0.0) faraday (>= 1) @@ -692,7 +696,6 @@ GEM snaptrade (2.0.156) faraday (>= 1.0.1, < 3.0) faraday-multipart (~> 1.0, >= 1.0.4) - sorbet-runtime (0.5.12163) stackprof (0.2.27) stimulus-rails (1.3.4) railties (>= 6.0.0) @@ -716,7 +719,8 @@ GEM terminal-table (4.0.0) unicode-display_width (>= 1.1.1, < 4) thor (1.4.0) - timeout (0.4.3) + timeout (0.6.1) + tsort (0.2.0) ttfunk (1.8.0) bigdecimal (~> 3.1) turbo-rails (2.0.16) @@ -786,6 +790,7 @@ DEPENDENCIES brakeman capybara climate_control + connection_pool (~> 2.5) countries csv debug @@ -834,6 +839,7 @@ DEPENDENCIES rack-cors rack-mini-profiler rails (~> 7.2.2) + rails-i18n rails-settings-cached rchardet redcarpet diff --git a/README.md b/README.md index 81d509be7..fe45b8bbb 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ To stay compliant and avoid trademark issues: With data-heavy apps, inevitably, there are performance issues. We've set up a public dashboard showing the problematic requests seen on the demo site, along with the stacktraces to help debug them. -https://www.skylight.io/app/applications/s6PEZSKwcklL/recent/6h/endpoints +[https://www.skylight.io/app/applications/s6PEZSKwcklL/recent/6h/endpoints](https://oss.skylight.io/app/applications/s6PEZSKwcklL/recent/6h/endpoints) Any contributions that help improve performance are very much welcome. diff --git a/app/assets/images/claw-dark.svg b/app/assets/images/claw-dark.svg new file mode 100644 index 000000000..9eba8a03e --- /dev/null +++ b/app/assets/images/claw-dark.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/assets/images/claw.svg b/app/assets/images/claw.svg new file mode 100644 index 000000000..3da342760 --- /dev/null +++ b/app/assets/images/claw.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css index 583b228a0..36b65b467 100644 --- a/app/assets/tailwind/application.css +++ b/app/assets/tailwind/application.css @@ -13,6 +13,7 @@ @import "./google-sign-in.css"; @import "./date-picker-dark-mode.css"; @import "./print-report.css"; +@import "./privacy-mode.css"; @layer components { .pcr-app{ diff --git a/app/assets/tailwind/maybe-design-system.css b/app/assets/tailwind/maybe-design-system.css index 184cbf3a6..e4ff87051 100644 --- a/app/assets/tailwind/maybe-design-system.css +++ b/app/assets/tailwind/maybe-design-system.css @@ -248,6 +248,18 @@ color: theme(colors.white) !important; } +/* Specific override for headings in prose under dark mode */ +.prose:where([data-theme=dark], [data-theme=dark] *) h1, +.prose:where([data-theme=dark], [data-theme=dark] *) h2, +.prose:where([data-theme=dark], [data-theme=dark] *) h3, +.prose:where([data-theme=dark], [data-theme=dark] *) h4, +.prose:where([data-theme=dark], [data-theme=dark] *) h5, +.prose:where([data-theme=dark], [data-theme=dark] *) h6, +.prose:where([data-theme=dark], [data-theme=dark] *) blockquote, +.prose:where([data-theme=dark], [data-theme=dark] *) thead th { + color: theme(colors.white) !important; +} + @layer base { [data-theme="dark"] { --color-success: var(--color-green-500); @@ -368,12 +380,14 @@ text-overflow: clip; } - select.form-field__input { + select.form-field__input, + button.form-field__input { @apply pr-10 appearance-none; background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); background-position: right -0.15rem center; background-repeat: no-repeat; background-size: 1.25rem 1.25rem; + text-align: left; } .form-field__radio { diff --git a/app/assets/tailwind/privacy-mode.css b/app/assets/tailwind/privacy-mode.css new file mode 100644 index 000000000..e57eaa246 --- /dev/null +++ b/app/assets/tailwind/privacy-mode.css @@ -0,0 +1,11 @@ +/* Privacy Mode - blurs financial numbers when activated */ +html.privacy-mode .privacy-sensitive { + filter: blur(8px); + user-select: none; + pointer-events: none; + transition: filter 0.2s ease; +} + +html:not(.privacy-mode) .privacy-sensitive { + transition: filter 0.2s ease; +} \ No newline at end of file diff --git a/app/components/DS/buttonish.rb b/app/components/DS/buttonish.rb index 7eeb5ee66..0c68d1dbe 100644 --- a/app/components/DS/buttonish.rb +++ b/app/components/DS/buttonish.rb @@ -80,7 +80,7 @@ class DS::Buttonish < DesignSystemComponent merged_base_classes, full_width ? "w-full justify-center" : nil, container_size_classes, - size_data.dig(:text_classes), + icon_only? ? nil : size_data.dig(:text_classes), variant_data.dig(:container_classes) ) end @@ -108,7 +108,7 @@ class DS::Buttonish < DesignSystemComponent end def icon_only? - variant.in?([ :icon, :icon_inverse ]) + variant.in?([ :icon, :icon_inverse ]) || (icon.present? && text.blank?) end private diff --git a/app/components/DS/dialog.html.erb b/app/components/DS/dialog.html.erb index a26b0ae7c..307303970 100644 --- a/app/components/DS/dialog.html.erb +++ b/app/components/DS/dialog.html.erb @@ -2,7 +2,7 @@ <%= tag.dialog class: "w-full h-full bg-transparent theme-dark:backdrop:bg-alpha-black-900 backdrop:bg-overlay pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)] #{(drawer? || responsive?) ? "lg:p-3" : "lg:p-1"}", **merged_opts do %> <%= tag.div class: dialog_outer_classes do %> <%= tag.div class: dialog_inner_classes, data: { DS__dialog_target: "content" } do %> -
+
"> <% if header? %> <%= header %> <% end %> diff --git a/app/components/DS/dialog.rb b/app/components/DS/dialog.rb index 536febf6c..e9c4eb3ae 100644 --- a/app/components/DS/dialog.rb +++ b/app/components/DS/dialog.rb @@ -33,7 +33,7 @@ class DS::Dialog < DesignSystemComponent end end - attr_reader :variant, :auto_open, :reload_on_close, :width, :disable_frame, :content_class, :disable_click_outside, :opts, :responsive + attr_reader :variant, :auto_open, :reload_on_close, :width, :disable_frame, :content_class, :disable_click_outside, :opts, :responsive, :scrollable VARIANTS = %w[modal drawer].freeze WIDTHS = { @@ -43,7 +43,7 @@ class DS::Dialog < DesignSystemComponent full: "lg:max-w-full" }.freeze - def initialize(variant: "modal", auto_open: true, reload_on_close: false, width: "md", frame: nil, disable_frame: false, content_class: nil, disable_click_outside: false, responsive: false, **opts) + def initialize(variant: "modal", auto_open: true, reload_on_close: false, width: "md", frame: nil, disable_frame: false, content_class: nil, disable_click_outside: false, responsive: false, scrollable: true, **opts) @variant = variant.to_sym @auto_open = auto_open @reload_on_close = reload_on_close @@ -53,6 +53,7 @@ class DS::Dialog < DesignSystemComponent @content_class = content_class @disable_click_outside = disable_click_outside @responsive = responsive + @scrollable = scrollable @opts = opts end @@ -97,7 +98,7 @@ class DS::Dialog < DesignSystemComponent end class_names( - "flex flex-col bg-container rounded-xl shadow-border-xs mx-3 lg:mx-0 w-full overflow-hidden", + "flex flex-col bg-container rounded-xl shadow-border-xs mx-3 lg:mx-0 w-full", variant_classes, content_class ) diff --git a/app/components/DS/menu.html.erb b/app/components/DS/menu.html.erb index d4f0ea8d8..ed6490184 100644 --- a/app/components/DS/menu.html.erb +++ b/app/components/DS/menu.html.erb @@ -1,4 +1,4 @@ -<%= tag.div data: { controller: "DS--menu", DS__menu_placement_value: placement, DS__menu_offset_value: offset, testid: testid } do %> +<%= tag.div data: { controller: "DS--menu", DS__menu_placement_value: placement, DS__menu_offset_value: offset, DS__menu_mobile_fullwidth_value: mobile_fullwidth, testid: testid } do %> <% if variant == :icon %> <%= render DS::Button.new(variant: "icon", icon: icon_vertical ? "more-vertical" : "more-horizontal", data: { DS__menu_target: "button" }) %> <% elsif variant == :button %> @@ -12,7 +12,7 @@ <% end %> <% end %> diff --git a/app/components/DS/menu.rb b/app/components/DS/menu.rb index 39ef35e97..32a14a472 100644 --- a/app/components/DS/menu.rb +++ b/app/components/DS/menu.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class DS::Menu < DesignSystemComponent - attr_reader :variant, :avatar_url, :initials, :placement, :offset, :icon_vertical, :no_padding, :testid + attr_reader :variant, :avatar_url, :initials, :placement, :offset, :icon_vertical, :no_padding, :testid, :mobile_fullwidth, :max_width renders_one :button, ->(**button_options, &block) do options_with_target = button_options.merge(data: { DS__menu_target: "button" }) @@ -23,7 +23,7 @@ class DS::Menu < DesignSystemComponent VARIANTS = %i[icon button avatar].freeze - def initialize(variant: "icon", avatar_url: nil, initials: nil, placement: "bottom-end", offset: 12, icon_vertical: false, no_padding: false, testid: nil) + def initialize(variant: "icon", avatar_url: nil, initials: nil, placement: "bottom-end", offset: 12, icon_vertical: false, no_padding: false, testid: nil, mobile_fullwidth: true, max_width: nil) @variant = variant.to_sym @avatar_url = avatar_url @initials = initials @@ -32,6 +32,8 @@ class DS::Menu < DesignSystemComponent @icon_vertical = icon_vertical @no_padding = no_padding @testid = testid + @mobile_fullwidth = mobile_fullwidth + @max_width = max_width raise ArgumentError, "Invalid variant: #{@variant}" unless VARIANTS.include?(@variant) end diff --git a/app/components/DS/menu_controller.js b/app/components/DS/menu_controller.js index 512358f6c..5e24b3a0f 100644 --- a/app/components/DS/menu_controller.js +++ b/app/components/DS/menu_controller.js @@ -1,6 +1,7 @@ import { autoUpdate, computePosition, + flip, offset, shift, } from "@floating-ui/dom"; @@ -16,6 +17,7 @@ export default class extends Controller { show: Boolean, placement: { type: String, default: "bottom-end" }, offset: { type: Number, default: 6 }, + mobileFullwidth: { type: Boolean, default: true }, }; connect() { @@ -105,13 +107,14 @@ export default class extends Controller { if (!this.buttonTarget || !this.contentTarget) return; const isSmallScreen = !window.matchMedia("(min-width: 768px)").matches; + const useMobileFullwidth = isSmallScreen && this.mobileFullwidthValue; computePosition(this.buttonTarget, this.contentTarget, { - placement: isSmallScreen ? "bottom" : this.placementValue, - middleware: [offset(this.offsetValue), shift({ padding: 5 })], + placement: useMobileFullwidth ? "bottom" : this.placementValue, + middleware: [offset(this.offsetValue), flip({ padding: 5 }), shift({ padding: 5 })], strategy: "fixed", }).then(({ x, y }) => { - if (isSmallScreen) { + if (useMobileFullwidth) { Object.assign(this.contentTarget.style, { position: "fixed", left: "0px", diff --git a/app/components/DS/select.html.erb b/app/components/DS/select.html.erb new file mode 100644 index 000000000..170d3ee26 --- /dev/null +++ b/app/components/DS/select.html.erb @@ -0,0 +1,94 @@ +<%# locals: form:, method:, collection:, options: {} %> + +
form-dropdown" data-action="dropdown:select->form-dropdown#onSelect"> +
+
+ <%= form.label method, options[:label], class: "form-field__label" if options[:label].present? %> + <%= form.hidden_field method, + value: @selected_value, + data: { + "form-dropdown-target": "input", + "auto-submit-target": "auto" + } %> + +
+
+ +
diff --git a/app/components/DS/select.rb b/app/components/DS/select.rb new file mode 100644 index 000000000..abbd48ada --- /dev/null +++ b/app/components/DS/select.rb @@ -0,0 +1,83 @@ +module DS + class Select < ViewComponent::Base + attr_reader :form, :method, :items, :selected_value, :placeholder, :variant, :searchable, :options + + VARIANTS = %i[simple logo badge].freeze + HEX_COLOR_REGEX = /\A#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?\z/ + RGB_COLOR_REGEX = /\Argb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\)\z/ + DEFAULT_COLOR = "#737373" + + def initialize(form:, method:, items:, selected: nil, placeholder: I18n.t("helpers.select.default_label"), variant: :simple, include_blank: nil, searchable: false, **options) + @form = form + @method = method + @placeholder = placeholder + @variant = variant + @searchable = searchable + @options = options + + normalized_items = normalize_items(items) + + if include_blank + normalized_items.unshift({ + value: nil, + label: include_blank, + object: nil + }) + end + + @items = normalized_items + @selected_value = selected + end + + def selected_item + items.find { |item| item[:value] == selected_value } + end + + # Returns the color for a given item (used in :badge variant) + def color_for(item) + obj = item[:object] + color = obj&.respond_to?(:color) ? obj.color : DEFAULT_COLOR + + return DEFAULT_COLOR unless color.is_a?(String) + + if color.match?(HEX_COLOR_REGEX) || color.match?(RGB_COLOR_REGEX) + color + else + DEFAULT_COLOR + end + end + + # Returns the lucide_icon name for a given item (used in :badge variant) + def icon_for(item) + obj = item[:object] + obj&.respond_to?(:lucide_icon) ? obj.lucide_icon : nil + end + + # Returns true if the item has a logo (used in :logo variant) + def logo_for(item) + obj = item[:object] + obj&.respond_to?(:logo_url) && obj.logo_url.present? ? Setting.transform_brand_fetch_url(obj.logo_url) : nil + end + + private + + def normalize_items(collection) + collection.map do |item| + case item + when Hash + { + value: item[:value], + label: item[:label], + object: item[:object] + } + else + { + value: item.id, + label: item.name, + object: item + } + end + end + end + end +end diff --git a/app/components/UI/account/chart.html.erb b/app/components/UI/account/chart.html.erb index ff54a5789..efcdca7d6 100644 --- a/app/components/UI/account/chart.html.erb +++ b/app/components/UI/account/chart.html.erb @@ -9,10 +9,10 @@ <% end %>
- <%= tag.p view_balance_money.format, class: "text-primary text-3xl font-medium truncate" %> + <%= tag.p view_balance_money.format, class: "text-primary text-3xl font-medium truncate privacy-sensitive" %> <% if converted_balance_money %> - <%= tag.p converted_balance_money.format, class: "text-sm font-medium text-secondary" %> + <%= tag.p converted_balance_money.format, class: "text-sm font-medium text-secondary privacy-sensitive" %> <% end %>
@@ -38,14 +38,14 @@ <%= turbo_frame_tag dom_id(@account, :chart_details) do %>
- <%= render partial: "shared/trend_change", locals: { trend: trend, comparison_label: period.comparison_label } %> + <%= render partial: "shared/trend_change", locals: { trend: trend, comparison_label: comparison_label } %>
<% if series.any? %>
<% else %> diff --git a/app/components/UI/account/chart.rb b/app/components/UI/account/chart.rb index 1e58529aa..61ae4d6fc 100644 --- a/app/components/UI/account/chart.rb +++ b/app/components/UI/account/chart.rb @@ -69,4 +69,15 @@ class UI::Account::Chart < ApplicationComponent def trend series.trend end + + def comparison_label + start_date = series.start_date + return period.comparison_label if start_date.blank? + + if start_date > period.start_date + "vs. available history" + else + period.comparison_label + end + end end diff --git a/app/controllers/account_sharings_controller.rb b/app/controllers/account_sharings_controller.rb new file mode 100644 index 000000000..f04bcee6e --- /dev/null +++ b/app/controllers/account_sharings_controller.rb @@ -0,0 +1,62 @@ +class AccountSharingsController < ApplicationController + before_action :set_account + + def show + @family_members = Current.family.users.where.not(id: @account.owner_id).where(active: true) + @account_shares = @account.account_shares.includes(:user).index_by(&:user_id) + end + + def update + # Non-owners can update their own include_in_finances preference + if !@account.owned_by?(Current.user) && params[:update_finance_inclusion].present? + share = @account.account_shares.find_by!(user: Current.user) + include_value = params.permit(:include_in_finances)[:include_in_finances] + share.update!(include_in_finances: ActiveModel::Type::Boolean.new.cast(include_value)) + redirect_back_or_to account_path(@account), notice: t("account_sharings.update.finance_toggle_success") + return + end + + unless @account.owned_by?(Current.user) + redirect_to account_path(@account), alert: t("account_sharings.update.not_owner") + return + end + + eligible_members = Current.family.users.where.not(id: @account.owner_id).where(active: true) + + AccountShare.transaction do + sharing_members_params.each do |member_params| + user = eligible_members.find_by(id: member_params[:user_id]) + next unless user + + share = @account.account_shares.find_by(user: user) + + if ActiveModel::Type::Boolean.new.cast(member_params[:shared]) + permission = AccountShare::PERMISSIONS.include?(member_params[:permission]) ? member_params[:permission] : (share&.permission || "read_only") + if share + share.update!(permission: permission) + else + @account.account_shares.create!(user: user, permission: permission, include_in_finances: true) + end + elsif share + share.destroy! + end + end + end + + redirect_back_or_to accounts_path, notice: t("account_sharings.update.success") + end + + private + + def set_account + @account = Current.user.accessible_accounts.find(params[:account_id]) + end + + def sharing_members_params + return [] unless params.dig(:sharing, :members) + + params.require(:sharing).permit( + members: [ :user_id, :shared, :permission ] + )[:members]&.values || [] + end +end diff --git a/app/controllers/accountable_sparklines_controller.rb b/app/controllers/accountable_sparklines_controller.rb index 45f01a2cb..9ba69ff56 100644 --- a/app/controllers/accountable_sparklines_controller.rb +++ b/app/controllers/accountable_sparklines_controller.rb @@ -7,15 +7,7 @@ class AccountableSparklinesController < ApplicationController # Use HTTP conditional GET so the client receives 304 Not Modified when possible. if stale?(etag: etag_key, last_modified: family.latest_sync_completed_at) @series = Rails.cache.fetch(etag_key, expires_in: 24.hours) do - builder = Balance::ChartSeriesBuilder.new( - account_ids: account_ids, - currency: family.currency, - period: Period.last_30_days, - favorable_direction: @accountable.favorable_direction, - interval: "1 day" - ) - - builder.balance_series + build_series end render layout: false @@ -35,7 +27,37 @@ class AccountableSparklinesController < ApplicationController family.accounts.visible.where(accountable_type: accountable.name).pluck(:id) end + def accounts + @accounts ||= family.accounts.visible.where(accountable_type: accountable.name) + end + + def build_series + return aggregate_normalized_series if requires_normalized_aggregation? + + Balance::ChartSeriesBuilder.new( + account_ids: account_ids, + currency: family.currency, + period: Period.last_30_days, + favorable_direction: @accountable.favorable_direction, + interval: "1 day" + ).balance_series + end + + def requires_normalized_aggregation? + accounts.any? { |account| account.linked? && account.balance_type == :investment } + end + + def aggregate_normalized_series + Balance::LinkedInvestmentSeriesNormalizer.aggregate_accounts( + accounts: accounts, + currency: family.currency, + period: Period.last_30_days, + favorable_direction: @accountable.favorable_direction, + interval: "1 day" + ) + end + def cache_key - family.build_cache_key("#{@accountable.name}_sparkline", invalidate_on_data_updates: true) + family.build_cache_key("#{@accountable.name}_sparkline_#{Account::Chartable::SPARKLINE_CACHE_VERSION}", invalidate_on_data_updates: true) end end diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 4258e86ad..9ae5767e5 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -1,20 +1,25 @@ class AccountsController < ApplicationController - before_action :set_account, only: %i[sync sparkline toggle_active show destroy unlink confirm_unlink select_provider] + include StreamExtensions + + before_action :set_account, only: %i[show sparkline sync set_default remove_default] + before_action :set_manageable_account, only: %i[toggle_active destroy unlink confirm_unlink select_provider] include Periodable def index + @accessible_account_ids = Current.user.accessible_accounts.pluck(:id) @manual_accounts = family.accounts .listable_manual + .where(id: @accessible_account_ids) .order(:name) - @plaid_items = family.plaid_items.ordered.includes(:syncs, :plaid_accounts) - @simplefin_items = family.simplefin_items.ordered.includes(:syncs) - @lunchflow_items = family.lunchflow_items.ordered.includes(:syncs, :lunchflow_accounts) - @enable_banking_items = family.enable_banking_items.ordered.includes(:syncs) - @coinstats_items = family.coinstats_items.ordered.includes(:coinstats_accounts, :accounts, :syncs) - @mercury_items = family.mercury_items.ordered.includes(:syncs, :mercury_accounts) - @coinbase_items = family.coinbase_items.ordered.includes(:coinbase_accounts, :accounts, :syncs) - @snaptrade_items = family.snaptrade_items.ordered.includes(:syncs, :snaptrade_accounts) - @indexa_capital_items = family.indexa_capital_items.ordered.includes(:syncs, :indexa_capital_accounts) + @plaid_items = visible_provider_items(family.plaid_items.ordered.includes(:syncs, :plaid_accounts)) + @simplefin_items = visible_provider_items(family.simplefin_items.ordered.includes(:syncs)) + @lunchflow_items = visible_provider_items(family.lunchflow_items.ordered.includes(:syncs, :lunchflow_accounts)) + @enable_banking_items = visible_provider_items(family.enable_banking_items.ordered.includes(:syncs)) + @coinstats_items = visible_provider_items(family.coinstats_items.ordered.includes(:coinstats_accounts, :accounts, :syncs)) + @mercury_items = visible_provider_items(family.mercury_items.ordered.includes(:syncs, :mercury_accounts)) + @coinbase_items = visible_provider_items(family.coinbase_items.ordered.includes(:coinbase_accounts, :accounts, :syncs)) + @snaptrade_items = visible_provider_items(family.snaptrade_items.ordered.includes(:syncs, :snaptrade_accounts)) + @indexa_capital_items = visible_provider_items(family.indexa_capital_items.ordered.includes(:syncs, :indexa_capital_accounts)) # Build sync stats maps for all providers build_sync_stats_maps @@ -42,7 +47,11 @@ class AccountsController < ApplicationController @q = params.fetch(:q, {}).permit(:search, status: []) entries = @account.entries.where(excluded: false).search(@q).reverse_chronological - @pagy, @entries = pagy(entries, limit: safe_per_page) + @pagy, @entries = pagy( + entries, + limit: safe_per_page, + params: request.query_parameters.except("tab").merge("tab" => "activity") + ) @activity_feed_data = Account::ActivityFeedData.new(@account, @entries) end @@ -66,7 +75,7 @@ class AccountsController < ApplicationController end def sparkline - etag_key = @account.family.build_cache_key("#{@account.id}_sparkline", invalidate_on_data_updates: true) + etag_key = @account.family.build_cache_key("#{@account.id}_sparkline_#{Account::Chartable::SPARKLINE_CACHE_VERSION}", invalidate_on_data_updates: true) # Short-circuit with 304 Not Modified when the client already has the latest version. # We defer the expensive series computation until we know the content is stale. @@ -85,12 +94,32 @@ class AccountsController < ApplicationController redirect_to accounts_path end + def set_default + unless @account.eligible_for_transaction_default? + redirect_to accounts_path, alert: t("accounts.set_default.depository_only") + return + end + + Current.user.update!(default_account: @account) + redirect_to accounts_path + end + + def remove_default + Current.user.update!(default_account: nil) + redirect_to accounts_path + end + def destroy if @account.linked? redirect_to account_path(@account), alert: t("accounts.destroy.cannot_delete_linked") else - @account.destroy_later - redirect_to accounts_path, notice: t("accounts.destroy.success", type: @account.accountable_type) + begin + @account.destroy_later + redirect_to accounts_path, notice: t("accounts.destroy.success", type: @account.accountable_type) + rescue => e + Rails.logger.error "Failed to schedule account #{@account.id} for deletion: #{e.message}" + redirect_to accounts_path, alert: t("accounts.destroy.failed") + end end end @@ -181,7 +210,26 @@ class AccountsController < ApplicationController end def set_account - @account = family.accounts.find(params[:id]) + @account = Current.user.accessible_accounts.find(params[:id]) + end + + def set_manageable_account + @account = Current.user.accessible_accounts.find(params[:id]) + permission = @account.permission_for(Current.user) + unless permission.in?([ :owner, :full_control ]) + respond_to do |format| + format.html { redirect_to account_path(@account), alert: t("accounts.not_authorized") } + format.turbo_stream { stream_redirect_to(account_path(@account), alert: t("accounts.not_authorized")) } + end + nil + end + end + + def visible_provider_items(items) + items.select do |item| + Current.user.admin? || + (item.respond_to?(:accounts) && (item.accounts.map(&:id) & @accessible_account_ids).any?) + end end # Builds sync stats maps for all provider types to avoid N+1 queries in views diff --git a/app/controllers/admin/invitations_controller.rb b/app/controllers/admin/invitations_controller.rb new file mode 100644 index 000000000..50dd7cff7 --- /dev/null +++ b/app/controllers/admin/invitations_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Admin + class InvitationsController < Admin::BaseController + def destroy + invitation = Invitation.find(params[:id]) + invitation.destroy! + redirect_to admin_users_path, notice: t(".success") + end + + def destroy_all + family = Family.find(params[:id]) + family.invitations.pending.destroy_all + redirect_to admin_users_path, notice: t(".success") + end + end +end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index d460b1ac5..a86fda917 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -13,7 +13,7 @@ module Admin scope = scope.where(role: params[:role]) if params[:role].present? scope = apply_trial_filter(scope) if params[:trial_status].present? - @users = scope.order( + users = scope.order( Arel.sql( "CASE " \ "WHEN subscriptions.status = 'trialing' THEN 0 " \ @@ -23,14 +23,22 @@ module Admin ) ) - family_ids = @users.map(&:family_id).uniq + family_ids = users.map(&:family_id).uniq @accounts_count_by_family = Account.where(family_id: family_ids).group(:family_id).count @entries_count_by_family = Entry.joins(:account).where(accounts: { family_id: family_ids }).group("accounts.family_id").count - user_ids = @users.map(&:id).uniq + user_ids = users.map(&:id).uniq @last_login_by_user = Session.where(user_id: user_ids).group(:user_id).maximum(:created_at) @sessions_count_by_user = Session.where(user_id: user_ids).group(:user_id).count + @families_with_users = users.group_by(&:family).sort_by do |family, _users| + -(@entries_count_by_family[family.id] || 0) + end + + @invitations_by_family = Invitation.pending + .where(family_id: family_ids) + .group_by(&:family_id) + @trials_expiring_in_7_days = Subscription .where(status: :trialing) .where(trial_ends_at: Time.current..7.days.from_now) diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index dea9cbe6d..e5e4cad3c 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -9,7 +9,7 @@ class Api::V1::AccountsController < Api::V1::BaseController def index # Test with Pagy pagination family = current_resource_owner.family - accounts_query = family.accounts.visible.alphabetically + accounts_query = family.accounts.accessible_by(current_resource_owner).visible.alphabetically # Handle pagination with Pagy @pagy, @accounts = pagy( diff --git a/app/controllers/api/v1/auth_controller.rb b/app/controllers/api/v1/auth_controller.rb index ae1744823..e522fb03f 100644 --- a/app/controllers/api/v1/auth_controller.rb +++ b/app/controllers/api/v1/auth_controller.rb @@ -140,6 +140,92 @@ module Api } end + def sso_link + linking_code = params[:linking_code] + cached = validate_linking_code(linking_code) + return unless cached + + user = User.authenticate_by(email: params[:email], password: params[:password]) + + unless user + render json: { error: "Invalid email or password" }, status: :unauthorized + return + end + + if user.otp_required? + render json: { error: "MFA users should sign in with email and password", mfa_required: true }, status: :unauthorized + return + end + + # Atomically claim the code before creating the identity + return render json: { error: "Linking code is invalid or expired" }, status: :unauthorized unless consume_linking_code!(linking_code) + + OidcIdentity.create_from_omniauth(build_omniauth_hash(cached), user) + + SsoAuditLog.log_link!( + user: user, + provider: cached[:provider], + request: request + ) + + issue_mobile_tokens(user, cached[:device_info]) + end + + def sso_create_account + linking_code = params[:linking_code] + cached = validate_linking_code(linking_code) + return unless cached + + email = cached[:email] + + # Check for a pending invitation for this email + invitation = Invitation.pending.find_by(email: email) + + unless invitation.present? || cached[:allow_account_creation] + render json: { error: "SSO account creation is disabled. Please contact an administrator." }, status: :forbidden + return + end + + # Atomically claim the code before creating the user + return render json: { error: "Linking code is invalid or expired" }, status: :unauthorized unless consume_linking_code!(linking_code) + + user = User.new( + email: email, + first_name: params[:first_name].presence || cached[:first_name], + last_name: params[:last_name].presence || cached[:last_name], + skip_password_validation: true + ) + + if invitation.present? + # Accept the pending invitation: join the existing family + user.family_id = invitation.family_id + user.role = invitation.role + else + user.family = Family.new + + provider_config = Rails.configuration.x.auth.sso_providers&.find { |p| p[:name] == cached[:provider] } + provider_default_role = provider_config&.dig(:settings, :default_role) + user.role = User.role_for_new_family_creator(fallback_role: provider_default_role || :admin) + end + + if user.save + # Mark invitation as accepted if one was used + invitation&.update!(accepted_at: Time.current) + + OidcIdentity.create_from_omniauth(build_omniauth_hash(cached), user) + + SsoAuditLog.log_jit_account_created!( + user: user, + provider: cached[:provider], + request: request + ) + + issue_mobile_tokens(user, cached[:device_info]) + else + render json: { errors: user.errors.full_messages }, status: :unprocessable_entity + end + end + def enable_ai user = current_resource_owner @@ -248,6 +334,48 @@ module Api } end + def build_omniauth_hash(cached) + OpenStruct.new( + provider: cached[:provider], + uid: cached[:uid], + info: OpenStruct.new(cached.slice(:email, :name, :first_name, :last_name)), + extra: OpenStruct.new(raw_info: OpenStruct.new(iss: cached[:issuer])) + ) + end + + def validate_linking_code(linking_code) + if linking_code.blank? + render json: { error: "Linking code is required" }, status: :bad_request + return nil + end + + cache_key = "mobile_sso_link:#{linking_code}" + cached = Rails.cache.read(cache_key) + + unless cached.present? + render json: { error: "Linking code is invalid or expired" }, status: :unauthorized + return nil + end + + cached + end + + # Atomically deletes the linking code from cache. + # Returns true only for the first caller; subsequent callers get false. + def consume_linking_code!(linking_code) + Rails.cache.delete("mobile_sso_link:#{linking_code}") + end + + def issue_mobile_tokens(user, device_info) + device_info = device_info.symbolize_keys if device_info.respond_to?(:symbolize_keys) + device = MobileDevice.upsert_device!(user, device_info) + token_response = device.issue_token! + + render json: token_response.merge(user: mobile_user_payload(user)) + rescue ActiveRecord::RecordInvalid => e + render json: { error: "Failed to register device: #{e.message}" }, status: :unprocessable_entity + end + def ensure_write_scope authorize_scope!(:write) end diff --git a/app/controllers/api/v1/balance_sheet_controller.rb b/app/controllers/api/v1/balance_sheet_controller.rb new file mode 100644 index 000000000..24ac01165 --- /dev/null +++ b/app/controllers/api/v1/balance_sheet_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# Returns the family's balance sheet data (net worth, assets, liabilities) +# with all monetary values converted to the family's primary currency. +class Api::V1::BalanceSheetController < Api::V1::BaseController + before_action :ensure_read_scope + + # GET /api/v1/balance_sheet + # Returns net worth, total assets, and total liabilities as Money objects. + def show + family = current_resource_owner.family + balance_sheet = family.balance_sheet + + render json: { + currency: family.currency, + net_worth: balance_sheet.net_worth_money.as_json, + assets: balance_sheet.assets.total_money.as_json, + liabilities: balance_sheet.liabilities.total_money.as_json + } + end + + private + + def ensure_read_scope + authorize_scope!(:read) + end +end diff --git a/app/controllers/api/v1/categories_controller.rb b/app/controllers/api/v1/categories_controller.rb index 571cd93ce..c810ffa25 100644 --- a/app/controllers/api/v1/categories_controller.rb +++ b/app/controllers/api/v1/categories_controller.rb @@ -62,11 +62,6 @@ class Api::V1::CategoriesController < Api::V1::BaseController end def apply_filters(query) - # Filter by classification (income/expense) - if params[:classification].present? - query = query.where(classification: params[:classification]) - end - # Filter for root categories only (no parent) if params[:roots_only].present? && ActiveModel::Type::Boolean.new.cast(params[:roots_only]) query = query.roots diff --git a/app/controllers/api/v1/imports_controller.rb b/app/controllers/api/v1/imports_controller.rb index b3b048bba..e6b78f8bd 100644 --- a/app/controllers/api/v1/imports_controller.rb +++ b/app/controllers/api/v1/imports_controller.rb @@ -52,8 +52,7 @@ class Api::V1::ImportsController < Api::V1::BaseController type = "TransactionImport" unless Import::TYPES.include?(type) # 2. Build the import object with permitted config attributes - @import = family.imports.build(import_config_params) - @import.type = type + @import = family.imports.build(import_config_params.merge(type: type)) @import.account_id = params[:account_id] if params[:account_id].present? # 3. Attach the uploaded file if present (with validation) diff --git a/app/controllers/api/v1/merchants_controller.rb b/app/controllers/api/v1/merchants_controller.rb index da6aa0ed3..c11c7246c 100644 --- a/app/controllers/api/v1/merchants_controller.rb +++ b/app/controllers/api/v1/merchants_controller.rb @@ -22,10 +22,15 @@ module Api # @return [Array] JSON array of merchant objects def index family = current_resource_owner.family + user = current_resource_owner # Single query with OR conditions - more efficient than Ruby deduplication family_merchant_ids = family.merchants.select(:id) - provider_merchant_ids = family.transactions.select(:merchant_id) + accessible_account_ids = family.accounts.accessible_by(user).select(:id) + provider_merchant_ids = Transaction.joins(:entry) + .where(entries: { account_id: accessible_account_ids }) + .where.not(merchant_id: nil) + .select(:merchant_id) @merchants = Merchant .where(id: family_merchant_ids) @@ -48,10 +53,11 @@ module Api # @return [Hash] JSON merchant object or error def show family = current_resource_owner.family + user = current_resource_owner @merchant = family.merchants.find_by(id: params[:id]) || Merchant.joins(transactions: :entry) - .where(entries: { account_id: family.accounts.select(:id) }) + .where(entries: { account_id: family.accounts.accessible_by(user).select(:id) }) .distinct .find_by(id: params[:id]) diff --git a/app/controllers/api/v1/transactions_controller.rb b/app/controllers/api/v1/transactions_controller.rb index 08ca81cee..b5942b7fc 100644 --- a/app/controllers/api/v1/transactions_controller.rb +++ b/app/controllers/api/v1/transactions_controller.rb @@ -10,7 +10,9 @@ class Api::V1::TransactionsController < Api::V1::BaseController def index family = current_resource_owner.family + accessible_account_ids = family.accounts.accessible_by(current_resource_owner).select(:id) transactions_query = family.transactions.visible + .joins(:entry).where(entries: { account_id: accessible_account_ids }) # Apply filters transactions_query = apply_filters(transactions_query) @@ -76,7 +78,7 @@ class Api::V1::TransactionsController < Api::V1::BaseController return end - account = family.accounts.find(transaction_params[:account_id]) + account = family.accounts.writable_by(current_resource_owner).find(transaction_params[:account_id]) @entry = account.entries.new(entry_params_for_create) if @entry.save @@ -105,6 +107,16 @@ class Api::V1::TransactionsController < Api::V1::BaseController end def update + if @entry.split_child? + render json: { error: "validation_failed", message: "Split child transactions cannot be edited directly. Use the split editor." }, status: :unprocessable_entity + return + end + + if @entry.split_parent? && split_financial_fields_changed? + render json: { error: "validation_failed", message: "Split parent amount, date, and type cannot be changed directly. Use the split editor." }, status: :unprocessable_entity + return + end + Entry.transaction do if @entry.update(entry_params_for_update) # Handle tags separately - only when explicitly provided in the request @@ -141,6 +153,11 @@ end end def destroy + if @entry.split_child? + render json: { error: "validation_failed", message: "Split child transactions cannot be deleted individually." }, status: :unprocessable_entity + return + end + @entry.destroy! @entry.sync_account_later @@ -162,7 +179,10 @@ end def set_transaction family = current_resource_owner.family - @transaction = family.transactions.find(params[:id]) + @transaction = family.transactions + .joins(entry: :account) + .merge(Account.accessible_by(current_resource_owner)) + .find(params[:id]) @entry = @transaction.entry rescue ActiveRecord::RecordNotFound render json: { @@ -313,6 +333,12 @@ end params[:transaction].key?(:tag_ids) end + def split_financial_fields_changed? + params.dig(:transaction, :amount).present? || + params.dig(:transaction, :date).present? || + params.dig(:transaction, :nature).present? + end + def calculate_signed_amount amount = transaction_params[:amount].to_f nature = transaction_params[:nature] diff --git a/app/controllers/api/v1/users_controller.rb b/app/controllers/api/v1/users_controller.rb index be01669f4..0a87bf43e 100644 --- a/app/controllers/api/v1/users_controller.rb +++ b/app/controllers/api/v1/users_controller.rb @@ -2,6 +2,7 @@ class Api::V1::UsersController < Api::V1::BaseController before_action :ensure_write_scope + before_action :ensure_admin, only: :reset def reset FamilyResetJob.perform_later(Current.family) @@ -24,4 +25,11 @@ class Api::V1::UsersController < Api::V1::BaseController def ensure_write_scope authorize_scope!(:write) end + + def ensure_admin + return true if current_resource_owner&.admin? + + render_json({ error: "forbidden", message: I18n.t("users.reset.unauthorized") }, status: :forbidden) + false + end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 310abfcd0..7604ed654 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,7 +1,7 @@ class ApplicationController < ActionController::Base include RestoreLayoutPreferences, Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable, Breadcrumbable, - FeatureGuardable, Notifiable, SafePagination + FeatureGuardable, Notifiable, SafePagination, AccountAuthorizable include Pundit::Authorization include Pagy::Backend @@ -40,6 +40,17 @@ class ApplicationController < ActionController::Base session[:pending_invitation_token] = token if invitation end + def require_admin! + return if Current.user&.admin? + + respond_to do |format| + format.html { redirect_to accounts_path, alert: t("shared.require_admin") } + format.turbo_stream { head :forbidden } + format.json { head :forbidden } + format.any { head :forbidden } + end + end + def detect_os user_agent = request.user_agent @os = case user_agent @@ -81,4 +92,14 @@ class ApplicationController < ActionController::Base def show_demo_warning? demo_host_match? end + + def accessible_accounts + Current.accessible_accounts + end + helper_method :accessible_accounts + + def finance_accounts + Current.finance_accounts + end + helper_method :finance_accounts end diff --git a/app/controllers/archived_exports_controller.rb b/app/controllers/archived_exports_controller.rb new file mode 100644 index 000000000..f626141a6 --- /dev/null +++ b/app/controllers/archived_exports_controller.rb @@ -0,0 +1,13 @@ +class ArchivedExportsController < ApplicationController + skip_authentication + + def show + export = ArchivedExport.find_by_download_token!(params[:token]) + + if export.downloadable? + redirect_to rails_blob_path(export.export_file, disposition: "attachment") + else + head :gone + end + end +end diff --git a/app/controllers/binance_items_controller.rb b/app/controllers/binance_items_controller.rb new file mode 100644 index 000000000..7f3471e58 --- /dev/null +++ b/app/controllers/binance_items_controller.rb @@ -0,0 +1,287 @@ +# frozen_string_literal: true + +class BinanceItemsController < ApplicationController + before_action :set_binance_item, only: [ :show, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ] + before_action :require_admin!, only: [ :new, :create, :select_accounts, :link_accounts, :select_existing_account, :link_existing_account, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ] + + def index + @binance_items = Current.family.binance_items.ordered + end + + def show + end + + def new + @binance_item = Current.family.binance_items.build + end + + def edit + end + + def create + @binance_item = Current.family.binance_items.build(binance_item_params) + @binance_item.name ||= t(".default_name") + + if @binance_item.save + @binance_item.set_binance_institution_defaults! + @binance_item.sync_later + + if turbo_frame_request? + flash.now[:notice] = t(".success") + @binance_items = Current.family.binance_items.ordered + render turbo_stream: [ + turbo_stream.update( + "binance-providers-panel", + partial: "settings/providers/binance_panel", + locals: { binance_items: @binance_items } + ), + *flash_notification_stream_items + ] + else + redirect_to settings_providers_path, notice: t(".success"), status: :see_other + end + else + @error_message = @binance_item.errors.full_messages.join(", ") + + if turbo_frame_request? + render turbo_stream: turbo_stream.replace( + "binance-providers-panel", + partial: "settings/providers/binance_panel", + locals: { error_message: @error_message } + ), status: :unprocessable_entity + else + redirect_to settings_providers_path, alert: @error_message, status: :see_other + end + end + end + + def update + if @binance_item.update(binance_item_params) + if turbo_frame_request? + flash.now[:notice] = t(".success") + @binance_items = Current.family.binance_items.ordered + render turbo_stream: [ + turbo_stream.update( + "binance-providers-panel", + partial: "settings/providers/binance_panel", + locals: { binance_items: @binance_items } + ), + *flash_notification_stream_items + ] + else + redirect_to settings_providers_path, notice: t(".success"), status: :see_other + end + else + @error_message = @binance_item.errors.full_messages.join(", ") + + if turbo_frame_request? + render turbo_stream: turbo_stream.replace( + "binance-providers-panel", + partial: "settings/providers/binance_panel", + locals: { error_message: @error_message } + ), status: :unprocessable_entity + else + redirect_to settings_providers_path, alert: @error_message, status: :see_other + end + end + end + + def destroy + @binance_item.destroy_later + redirect_to settings_providers_path, notice: t(".success") + end + + def sync + unless @binance_item.syncing? + @binance_item.sync_later + end + + respond_to do |format| + format.html { redirect_back_or_to accounts_path } + format.json { head :ok } + end + end + + def select_accounts + redirect_to settings_providers_path + end + + def link_accounts + redirect_to settings_providers_path + end + + def select_existing_account + @account = Current.family.accounts.find(params[:account_id]) + + @available_binance_accounts = Current.family.binance_items + .includes(binance_accounts: [ :account, { account_provider: :account } ]) + .flat_map(&:binance_accounts) + .select { |ba| ba.account.present? || ba.account_provider.nil? } + .sort_by { |ba| ba.updated_at || ba.created_at } + .reverse + + render :select_existing_account, layout: false + end + + def link_existing_account + @account = Current.family.accounts.find(params[:account_id]) + + binance_account = BinanceAccount + .joins(:binance_item) + .where(id: params[:binance_account_id], binance_items: { family_id: Current.family.id }) + .first + + unless binance_account + alert_msg = t(".errors.invalid_binance_account") + if turbo_frame_request? + flash.now[:alert] = alert_msg + render turbo_stream: Array(flash_notification_stream_items) + else + redirect_to account_path(@account), alert: alert_msg + end + return + end + + if @account.account_providers.any? || @account.plaid_account_id.present? || @account.simplefin_account_id.present? + alert_msg = t(".errors.only_manual") + if turbo_frame_request? + flash.now[:alert] = alert_msg + return render turbo_stream: Array(flash_notification_stream_items) + else + return redirect_to account_path(@account), alert: alert_msg + end + end + + unless @account.crypto? + alert_msg = t(".errors.only_manual") + if turbo_frame_request? + flash.now[:alert] = alert_msg + return render turbo_stream: Array(flash_notification_stream_items) + else + return redirect_to account_path(@account), alert: alert_msg + end + end + + Account.transaction do + binance_account.lock! + ap = AccountProvider.find_or_initialize_by(provider: binance_account) + previous_account = ap.account + ap.account_id = @account.id + ap.save! + + # Orphan cleanup (detaching the old account from this provider) is handled + # by the background sync job; no immediate action is required here. + if previous_account && previous_account.id != @account.id && previous_account.family_id == @account.family_id + Rails.logger.info("Binance: re-linked BinanceAccount #{binance_account.id} from account ##{previous_account.id} to ##{@account.id}") + end + end + + if turbo_frame_request? + item = binance_account.binance_item.reload + @binance_items = Current.family.binance_items.ordered.includes(:syncs) + @manual_accounts = Account.uncached { Current.family.accounts.visible_manual.order(:name).to_a } + + flash.now[:notice] = t(".success") + @account.reload + manual_accounts_stream = if @manual_accounts.any? + turbo_stream.update("manual-accounts", partial: "accounts/index/manual_accounts", locals: { accounts: @manual_accounts }) + else + turbo_stream.replace("manual-accounts", view_context.tag.div(id: "manual-accounts")) + end + + render turbo_stream: [ + turbo_stream.replace( + ActionView::RecordIdentifier.dom_id(item), + partial: "binance_items/binance_item", + locals: { binance_item: item } + ), + manual_accounts_stream, + *Array(flash_notification_stream_items) + ] + else + redirect_to accounts_path, notice: t(".success") + end + end + + def setup_accounts + @binance_accounts = @binance_item.binance_accounts + .left_joins(:account_provider) + .where(account_providers: { id: nil }) + .order(:name) + end + + def complete_account_setup + selected_accounts = Array(params[:selected_accounts]).reject(&:blank?) + created_accounts = [] + + selected_accounts.each do |binance_account_id| + ba = @binance_item.binance_accounts.find_by(id: binance_account_id) + next unless ba + + begin + ba.with_lock do + next if ba.account.present? + + account = Account.create_from_binance_account(ba) + provider_link = ba.ensure_account_provider!(account) + + if provider_link + created_accounts << account + else + account.destroy! + end + end + rescue StandardError => e + Rails.logger.error("Failed to setup account for BinanceAccount #{ba.id}: #{e.message}") + next + end + + ba.reload + + begin + BinanceAccount::HoldingsProcessor.new(ba).process + rescue StandardError => e + Rails.logger.error("Failed to process holdings for #{ba.id}: #{e.message}") + end + end + + unlinked_remaining = @binance_item.binance_accounts + .left_joins(:account_provider) + .where(account_providers: { id: nil }) + .count + @binance_item.update!(pending_account_setup: unlinked_remaining > 0) + + if created_accounts.any? + flash.now[:notice] = t(".success", count: created_accounts.count) + elsif selected_accounts.empty? + flash.now[:notice] = t(".none_selected") + else + flash.now[:notice] = t(".no_accounts") + end + + @binance_item.sync_later if created_accounts.any? + + if turbo_frame_request? + @binance_items = Current.family.binance_items.ordered.includes(:syncs) + render turbo_stream: [ + turbo_stream.replace( + ActionView::RecordIdentifier.dom_id(@binance_item), + partial: "binance_items/binance_item", + locals: { binance_item: @binance_item } + ) + ] + Array(flash_notification_stream_items) + else + redirect_to accounts_path, status: :see_other + end + end + + private + + def set_binance_item + @binance_item = Current.family.binance_items.find(params[:id]) + end + + def binance_item_params + params.require(:binance_item).permit(:name, :sync_start_date, :api_key, :api_secret) + end +end diff --git a/app/controllers/budget_categories_controller.rb b/app/controllers/budget_categories_controller.rb index b779a0224..0490a5f61 100644 --- a/app/controllers/budget_categories_controller.rb +++ b/app/controllers/budget_categories_controller.rb @@ -23,22 +23,22 @@ class BudgetCategoriesController < ApplicationController def update @budget_category = Current.family.budget_categories.find(params[:id]) + @budget_category.update_budgeted_spending!(budgeted_spending_param) - if @budget_category.update(budget_category_params) - respond_to do |format| - format.turbo_stream - format.html { redirect_to budget_budget_categories_path(@budget) } - end - else - render :index, status: :unprocessable_entity + respond_to do |format| + format.turbo_stream + format.html { redirect_to budget_budget_categories_path(@budget) } end + rescue ActiveRecord::RecordInvalid + render :index, status: :unprocessable_entity end private - def budget_category_params - params.require(:budget_category).permit(:budgeted_spending).tap do |params| - params[:budgeted_spending] = params[:budgeted_spending].presence || 0 - end + def budgeted_spending_param + params.require(:budget_category) + .permit(:budgeted_spending) + .fetch(:budgeted_spending, nil) + .presence || 0 end def set_budget diff --git a/app/controllers/budgets_controller.rb b/app/controllers/budgets_controller.rb index 1ec8e81b6..db04a4720 100644 --- a/app/controllers/budgets_controller.rb +++ b/app/controllers/budgets_controller.rb @@ -1,11 +1,12 @@ class BudgetsController < ApplicationController - before_action :set_budget, only: %i[show edit update] + before_action :set_budget, only: %i[show edit update copy_previous] def index redirect_to_current_month_budget end def show + @source_budget = @budget.most_recent_initialized_budget unless @budget.initialized? end def edit @@ -17,6 +18,22 @@ class BudgetsController < ApplicationController redirect_to budget_budget_categories_path(@budget) end + def copy_previous + if @budget.initialized? + redirect_to budget_path(@budget), alert: t("budgets.copy_previous.already_initialized") + return + end + + source_budget = @budget.most_recent_initialized_budget + + if source_budget + @budget.copy_from!(source_budget) + redirect_to budget_budget_categories_path(@budget), notice: t("budgets.copy_previous.success", source_name: source_budget.name) + else + redirect_to budget_path(@budget), alert: t("budgets.copy_previous.no_source") + end + end + def picker render partial: "budgets/picker", locals: { family: Current.family, @@ -36,12 +53,12 @@ class BudgetsController < ApplicationController def set_budget start_date = Budget.param_to_date(params[:month_year], family: Current.family) - @budget = Budget.find_or_bootstrap(Current.family, start_date: start_date) + @budget = Budget.find_or_bootstrap(Current.family, start_date: start_date, user: Current.user) raise ActiveRecord::RecordNotFound unless @budget end def redirect_to_current_month_budget - current_budget = Budget.find_or_bootstrap(Current.family, start_date: Date.current) + current_budget = Budget.find_or_bootstrap(Current.family, start_date: Date.current, user: Current.user) redirect_to budget_path(current_budget) end end diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index b1516f863..6d5e6b9fc 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -87,6 +87,6 @@ class CategoriesController < ApplicationController end def category_params - params.require(:category).permit(:name, :color, :parent_id, :classification, :lucide_icon) + params.require(:category).permit(:name, :color, :parent_id, :lucide_icon) end end diff --git a/app/controllers/coinbase_items_controller.rb b/app/controllers/coinbase_items_controller.rb index b3a1578b9..498853574 100644 --- a/app/controllers/coinbase_items_controller.rb +++ b/app/controllers/coinbase_items_controller.rb @@ -1,5 +1,6 @@ class CoinbaseItemsController < ApplicationController before_action :set_coinbase_item, only: [ :show, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ] + before_action :require_admin!, only: [ :new, :create, :preload_accounts, :select_accounts, :link_accounts, :select_existing_account, :link_existing_account, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ] def index @coinbase_items = Current.family.coinbase_items.ordered diff --git a/app/controllers/coinstats_items_controller.rb b/app/controllers/coinstats_items_controller.rb index 9e47d8f1a..37c28860a 100644 --- a/app/controllers/coinstats_items_controller.rb +++ b/app/controllers/coinstats_items_controller.rb @@ -1,5 +1,6 @@ class CoinstatsItemsController < ApplicationController before_action :set_coinstats_item, only: [ :show, :edit, :update, :destroy, :sync ] + before_action :require_admin!, only: [ :new, :create, :edit, :update, :destroy, :sync, :link_wallet, :link_exchange ] def index @coinstats_items = Current.family.coinstats_items.ordered @@ -12,6 +13,7 @@ class CoinstatsItemsController < ApplicationController @coinstats_item = Current.family.coinstats_items.build @coinstats_items = Current.family.coinstats_items.where.not(api_key: nil) @blockchains = fetch_blockchain_options(@coinstats_items.first) + @exchanges = fetch_exchange_options(@coinstats_items.first) end def create @@ -88,6 +90,52 @@ class CoinstatsItemsController < ApplicationController render_link_wallet_error(t(".error", message: e.message)) end + def link_exchange + coinstats_item_id = params[:coinstats_item_id].presence + @exchange_connection_id = params[:exchange_connection_id]&.to_s&.strip.presence + @exchange_connection_name = params[:exchange_connection_name]&.to_s&.strip.presence + + unless coinstats_item_id && @exchange_connection_id + return render_link_exchange_error(t(".missing_params")) + end + + @coinstats_item = Current.family.coinstats_items.find(coinstats_item_id) + exchange = find_exchange_option(@coinstats_item, @exchange_connection_id) + return render_link_exchange_error(t(".invalid_exchange")) unless exchange + + allowed_field_keys = Array(exchange[:connection_fields]).filter_map { |field| field[:key].presence&.to_s } + connection_fields_hash = extract_connection_fields_hash(params[:connection_fields]) + @exchange_connection_fields = connection_fields_hash + .slice(*allowed_field_keys) + .transform_values { |value| value.to_s.strip } + .compact_blank + @exchange_connection_name ||= exchange[:name].presence || @exchange_connection_id.to_s.titleize + + unless @exchange_connection_fields.present? + return render_link_exchange_error(t(".missing_params")) + end + + result = CoinstatsItem::ExchangeLinker.new( + @coinstats_item, + connection_id: @exchange_connection_id, + connection_fields: @exchange_connection_fields, + name: @exchange_connection_name + ).link + + if result.success? + redirect_to accounts_path, + notice: t(".success", name: @exchange_connection_name.presence || @exchange_connection_id.to_s.titleize), + status: :see_other + else + render_link_exchange_error(result.errors.join("; ").presence || t(".failed")) + end + rescue Provider::Coinstats::Error => e + render_link_exchange_error(t(".error", message: e.message)) + rescue => e + Rails.logger.error("CoinStats link exchange error: #{e.class} - #{e.message}") + render_link_exchange_error(t(".failed")) + end + private def set_coinstats_item @@ -148,11 +196,23 @@ class CoinstatsItemsController < ApplicationController def render_link_wallet_error(error_message) @error_message = error_message - @coinstats_items = Current.family.coinstats_items.where.not(api_key: nil) - @blockchains = fetch_blockchain_options(@coinstats_items.first) + prepare_link_form_state render :new, status: :unprocessable_entity end + def render_link_exchange_error(error_message) + @error_message = error_message + prepare_link_form_state + render :new, status: :unprocessable_entity + end + + def prepare_link_form_state + @coinstats_items = Current.family.coinstats_items.where.not(api_key: nil) + selected_item = @coinstats_items.first + @blockchains = fetch_blockchain_options(selected_item) + @exchanges = fetch_exchange_options(selected_item) + end + def fetch_blockchain_options(coinstats_item) return [] unless coinstats_item&.api_key.present? @@ -166,4 +226,31 @@ class CoinstatsItemsController < ApplicationController flash.now[:alert] = t("coinstats_items.new.blockchain_fetch_error") [] end + + def fetch_exchange_options(coinstats_item) + return [] unless coinstats_item&.api_key.present? + + @exchange_options_by_item ||= {} + @exchange_options_by_item[coinstats_item.id] ||= Provider::Coinstats.new(coinstats_item.api_key).exchange_options + rescue Provider::Coinstats::Error => e + Rails.logger.error("CoinStats exchange fetch failed: item_id=#{coinstats_item.id} error=#{e.class} message=#{e.message}") + [] + rescue StandardError => e + Rails.logger.error("CoinStats exchange fetch failed: item_id=#{coinstats_item.id} error=#{e.class} message=#{e.message}") + [] + end + + def extract_connection_fields_hash(connection_fields_param) + if connection_fields_param.respond_to?(:to_unsafe_h) + connection_fields_param.to_unsafe_h + elsif connection_fields_param.respond_to?(:to_h) + connection_fields_param.to_h + else + {} + end + end + + def find_exchange_option(coinstats_item, connection_id) + fetch_exchange_options(coinstats_item).find { |exchange| exchange[:connection_id] == connection_id } + end end diff --git a/app/controllers/concerns/account_authorizable.rb b/app/controllers/concerns/account_authorizable.rb new file mode 100644 index 000000000..70bbdd9a4 --- /dev/null +++ b/app/controllers/concerns/account_authorizable.rb @@ -0,0 +1,29 @@ +module AccountAuthorizable + extend ActiveSupport::Concern + + included do + include StreamExtensions + end + + private + + def require_account_permission!(account, level = :write, redirect_path: nil) + permission = account.permission_for(Current.user) + + allowed = case level + when :write then permission.in?([ :owner, :full_control ]) + when :annotate then permission.in?([ :owner, :full_control, :read_write ]) + when :owner then permission == :owner + else false + end + + return true if allowed + + path = redirect_path || account_path(account) + respond_to do |format| + format.html { redirect_back_or_to path, alert: t("accounts.not_authorized") } + format.turbo_stream { stream_redirect_back_or_to(path, alert: t("accounts.not_authorized")) } + end + false + end +end diff --git a/app/controllers/concerns/accountable_resource.rb b/app/controllers/concerns/accountable_resource.rb index 007a5f33a..23fd76107 100644 --- a/app/controllers/concerns/accountable_resource.rb +++ b/app/controllers/concerns/accountable_resource.rb @@ -2,9 +2,10 @@ module AccountableResource extend ActiveSupport::Concern included do - include Periodable + include Periodable, StreamExtensions - before_action :set_account, only: [ :show, :edit, :update ] + before_action :set_account, only: [ :show ] + before_action :set_manageable_account, only: [ :edit, :update ] before_action :set_link_options, only: :new end @@ -34,8 +35,18 @@ module AccountableResource end def create - @account = Current.family.accounts.create_and_sync(account_params.except(:return_to)) - @account.lock_saved_attributes! + opening_balance_date = begin + account_params[:opening_balance_date].presence&.to_date + rescue Date::Error + nil + end || (Time.zone.today - 2.years) + Account.transaction do + @account = Current.family.accounts.create_and_sync( + account_params.except(:return_to, :opening_balance_date).merge(owner: Current.user), + opening_balance_date: opening_balance_date + ) + @account.lock_saved_attributes! + end redirect_to account_params[:return_to].presence || @account, notice: t("accounts.create.success", type: accountable_type.name.underscore.humanize) end @@ -52,7 +63,7 @@ module AccountableResource end # Update remaining account attributes - update_params = account_params.except(:return_to, :balance, :currency) + update_params = account_params.except(:return_to, :balance, :currency, :opening_balance_date) unless @account.update(update_params) @error_message = @account.errors.full_messages.join(", ") render :edit, status: :unprocessable_entity @@ -79,12 +90,18 @@ module AccountableResource end def set_account - @account = Current.family.accounts.find(params[:id]) + @account = Current.user.accessible_accounts.find(params[:id]) + end + + def set_manageable_account + @account = Current.user.accessible_accounts.find(params[:id]) + require_account_permission!(@account) end def account_params params.require(:account).permit( :name, :balance, :subtype, :currency, :accountable_type, :return_to, + :opening_balance_date, :institution_name, :institution_domain, :notes, accountable_attributes: self.class.permitted_accountable_attributes ) diff --git a/app/controllers/concerns/entryable_resource.rb b/app/controllers/concerns/entryable_resource.rb index 443b04832..13f5798d0 100644 --- a/app/controllers/concerns/entryable_resource.rb +++ b/app/controllers/concerns/entryable_resource.rb @@ -5,13 +5,15 @@ module EntryableResource include StreamExtensions, ActionView::RecordIdentifier before_action :set_entry, only: %i[show update destroy] + + helper_method :can_edit_entry?, :can_annotate_entry? end def show end def new - account = Current.family.accounts.find_by(id: params[:account_id]) + account = accessible_accounts.find_by(id: params[:account_id]) @entry = Current.family.entries.new( account: account, @@ -29,11 +31,12 @@ module EntryableResource end def destroy - account = @entry.account + return unless require_account_permission!(@entry.account) + @entry.destroy! @entry.sync_account_later - redirect_back_or_to account_path(account), notice: t("account.entries.destroy.success") + redirect_back_or_to account_path(@entry.account), notice: t("account.entries.destroy.success") end private @@ -42,6 +45,21 @@ module EntryableResource end def set_entry - @entry = Current.family.entries.find(params[:id]) + @entry = Current.family.entries + .joins(:account) + .merge(Account.accessible_by(Current.user)) + .find(params[:id]) + end + + def entry_permission + @entry_permission ||= @entry&.account&.permission_for(Current.user) + end + + def can_edit_entry? + entry_permission.in?([ :owner, :full_control ]) + end + + def can_annotate_entry? + entry_permission.in?([ :owner, :full_control, :read_write ]) end end diff --git a/app/controllers/concerns/invitable.rb b/app/controllers/concerns/invitable.rb index a295e859f..dc93b30ec 100644 --- a/app/controllers/concerns/invitable.rb +++ b/app/controllers/concerns/invitable.rb @@ -9,7 +9,7 @@ module Invitable def invite_code_required? return false if @invitation.present? if self_hosted? - Setting.onboarding_state == "invite_only" + Setting.onboarding_state == "invite_only" && Setting.invite_only_default_family_id.blank? else ENV["REQUIRE_INVITE_CODE"] == "true" end diff --git a/app/controllers/enable_banking_items_controller.rb b/app/controllers/enable_banking_items_controller.rb index bd165d7dd..6cb8b1fc8 100644 --- a/app/controllers/enable_banking_items_controller.rb +++ b/app/controllers/enable_banking_items_controller.rb @@ -1,6 +1,7 @@ class EnableBankingItemsController < ApplicationController include EnableBankingItems::MapsHelper before_action :set_enable_banking_item, only: [ :update, :destroy, :sync, :select_bank, :authorize, :reauthorize, :setup_accounts, :complete_account_setup, :new_connection ] + before_action :require_admin!, only: [ :new, :create, :link_accounts, :select_existing_account, :link_existing_account, :update, :destroy, :sync, :select_bank, :authorize, :reauthorize, :setup_accounts, :complete_account_setup, :new_connection ] skip_before_action :verify_authenticity_token, only: [ :callback ] def new @@ -540,13 +541,8 @@ class EnableBankingItemsController < ApplicationController ) end - # Generate the callback URL for Enable Banking OAuth - # In production, uses the standard Rails route - # In development, uses DEV_WEBHOOKS_URL if set (e.g., ngrok URL) def enable_banking_callback_url - return callback_enable_banking_items_url if Rails.env.production? - - ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/")) + "/enable_banking_items/callback" + helpers.enable_banking_callback_url end # Validate redirect URLs from Enable Banking API to prevent open redirect attacks diff --git a/app/controllers/family_exports_controller.rb b/app/controllers/family_exports_controller.rb index d3a9163f5..cc9226a54 100644 --- a/app/controllers/family_exports_controller.rb +++ b/app/controllers/family_exports_controller.rb @@ -26,7 +26,11 @@ class FamilyExportsController < ApplicationController [ t("breadcrumbs.home"), root_path ], [ t("breadcrumbs.exports"), family_exports_path ] ] - render layout: "settings" + + respond_to do |format| + format.html { render layout: "settings" } + format.turbo_stream { redirect_to family_exports_path } + end end def download diff --git a/app/controllers/family_merchants_controller.rb b/app/controllers/family_merchants_controller.rb index 642f8d646..04e8fc15d 100644 --- a/app/controllers/family_merchants_controller.rb +++ b/app/controllers/family_merchants_controller.rb @@ -6,7 +6,7 @@ class FamilyMerchantsController < ApplicationController # Show all merchants for this family @family_merchants = Current.family.merchants.alphabetically - @provider_merchants = Current.family.assigned_merchants.where(type: "ProviderMerchant").alphabetically + @provider_merchants = Current.family.assigned_merchants_for(Current.user).where(type: "ProviderMerchant").alphabetically # Show recently unlinked ProviderMerchants (within last 30 days) # Exclude merchants that are already assigned to transactions (they appear in provider_merchants) @@ -17,6 +17,9 @@ class FamilyMerchantsController < ApplicationController assigned_ids = @provider_merchants.pluck(:id) @unlinked_merchants = ProviderMerchant.where(id: recently_unlinked_ids - assigned_ids).alphabetically + @enhanceable_count = @provider_merchants.where(website_url: [ nil, "" ]).count + @llm_available = Provider::Registry.get_provider(:openai).present? + render layout: "settings" end @@ -42,11 +45,21 @@ class FamilyMerchantsController < ApplicationController def update if @merchant.is_a?(ProviderMerchant) - # Convert ProviderMerchant to FamilyMerchant for this family only - @family_merchant = @merchant.convert_to_family_merchant_for(Current.family, merchant_params) - respond_to do |format| - format.html { redirect_to family_merchants_path, notice: t(".converted_success") } - format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, family_merchants_path) } + if merchant_params[:name].present? && merchant_params[:name] != @merchant.name + # Name changed — convert ProviderMerchant to FamilyMerchant for this family only + @family_merchant = @merchant.convert_to_family_merchant_for(Current.family, merchant_params) + respond_to do |format| + format.html { redirect_to family_merchants_path, notice: t(".converted_success") } + format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, family_merchants_path) } + end + else + # Only website changed — update the ProviderMerchant directly + @merchant.update!(merchant_params.slice(:website_url)) + @merchant.generate_logo_url_from_website! + respond_to do |format| + format.html { redirect_to family_merchants_path, notice: t(".success") } + format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, family_merchants_path) } + end end elsif @merchant.update(merchant_params) respond_to do |format| @@ -72,6 +85,19 @@ class FamilyMerchantsController < ApplicationController end end + def enhance + cache_key = "enhance_provider_merchants:#{Current.family.id}" + + already_running = !Rails.cache.write(cache_key, true, expires_in: 10.minutes, unless_exist: true) + + if already_running + return redirect_to family_merchants_path, alert: t(".already_running") + end + + EnhanceProviderMerchantsJob.perform_later(Current.family) + redirect_to family_merchants_path, notice: t(".success") + end + def merge @merchants = all_family_merchants end diff --git a/app/controllers/holdings_controller.rb b/app/controllers/holdings_controller.rb index f8ba811cc..d51fbc411 100644 --- a/app/controllers/holdings_controller.rb +++ b/app/controllers/holdings_controller.rb @@ -1,11 +1,15 @@ class HoldingsController < ApplicationController - before_action :set_holding, only: %i[show update destroy unlock_cost_basis remap_security reset_security] + include StreamExtensions + + before_action :set_holding, only: %i[show update destroy unlock_cost_basis remap_security reset_security sync_prices] + before_action :require_holding_write_permission!, only: %i[update destroy unlock_cost_basis remap_security reset_security sync_prices] def index - @account = Current.family.accounts.find(params[:account_id]) + @account = accessible_accounts.find(params[:account_id]) end def show + @last_price_updated = @holding.security.prices.maximum(:updated_at) end def update @@ -70,6 +74,13 @@ class HoldingsController < ApplicationController return end + # The user explicitly selected this security from provider search results, + # so we know the provider can handle it. Bring it back online if it was + # previously marked offline (e.g. by a failed QIF import resolution). + if new_security.offline? + new_security.update!(offline: false, failed_fetch_count: 0, failed_fetch_at: nil) + end + @holding.remap_security!(new_security) flash[:notice] = t(".success") @@ -79,6 +90,44 @@ class HoldingsController < ApplicationController end end + def sync_prices + security = @holding.security + + if security.offline? + redirect_to account_path(@holding.account, tab: "holdings"), + alert: t("holdings.sync_prices.unavailable") + return + end + + prices_updated, @provider_error = security.import_provider_prices( + start_date: 31.days.ago.to_date, + end_date: Date.current, + clear_cache: true + ) + security.import_provider_details + + @last_price_updated = @holding.security.prices.maximum(:updated_at) + + if prices_updated == 0 + @provider_error = @provider_error.presence || t("holdings.sync_prices.provider_error") + respond_to do |format| + format.html { redirect_to account_path(@holding.account, tab: "holdings"), alert: @provider_error } + format.turbo_stream + end + return + end + + strategy = @holding.account.linked? ? :reverse : :forward + Balance::Materializer.new(@holding.account, strategy: strategy, security_ids: [ @holding.security_id ]).materialize_balances + @holding.reload + @last_price_updated = @holding.security.prices.maximum(:updated_at) + + respond_to do |format| + format.html { redirect_to account_path(@holding.account, tab: "holdings"), notice: t("holdings.sync_prices.success") } + format.turbo_stream + end + end + def reset_security @holding.reset_security_to_provider! flash[:notice] = t(".success") @@ -91,7 +140,14 @@ class HoldingsController < ApplicationController private def set_holding - @holding = Current.family.holdings.find(params[:id]) + @holding = Current.family.holdings + .joins(:account) + .merge(Account.accessible_by(Current.user)) + .find(params[:id]) + end + + def require_holding_write_permission! + require_account_permission!(@holding.account) end def holding_params diff --git a/app/controllers/import/cleans_controller.rb b/app/controllers/import/cleans_controller.rb index 2c502f0a1..7d91f2134 100644 --- a/app/controllers/import/cleans_controller.rb +++ b/app/controllers/import/cleans_controller.rb @@ -9,7 +9,7 @@ class Import::CleansController < ApplicationController return redirect_to redirect_path, alert: "Please configure your import before proceeding." end - rows = @import.rows.ordered + rows = @import.rows_ordered if params[:view] == "errors" rows = rows.reject { |row| row.valid? } diff --git a/app/controllers/import/configurations_controller.rb b/app/controllers/import/configurations_controller.rb index 6602e3fbe..65c47fc68 100644 --- a/app/controllers/import/configurations_controller.rb +++ b/app/controllers/import/configurations_controller.rb @@ -5,7 +5,8 @@ class Import::ConfigurationsController < ApplicationController def show # PDF imports are auto-configured from AI extraction, skip to clean step - redirect_to import_clean_path(@import) if @import.is_a?(PdfImport) + redirect_to import_clean_path(@import) and return if @import.is_a?(PdfImport) + redirect_to import_qif_category_selection_path(@import) and return if @import.is_a?(QifImport) end def update diff --git a/app/controllers/import/qif_category_selections_controller.rb b/app/controllers/import/qif_category_selections_controller.rb new file mode 100644 index 000000000..2ed7b195a --- /dev/null +++ b/app/controllers/import/qif_category_selections_controller.rb @@ -0,0 +1,81 @@ +class Import::QifCategorySelectionsController < ApplicationController + layout "imports" + + before_action :set_import + + def show + valid_formats = @import.valid_date_formats_with_preview + @date_formats = valid_formats.map { |f| [ f[:label], f[:format] ] } + @date_previews = valid_formats.each_with_object({}) { |f, h| h[f[:format]] = f[:preview] } + @categories = @import.row_categories + @tags = @import.row_tags + @category_counts = @import.rows.group(:category).count.reject { |k, _| k.blank? } + @tag_counts = compute_tag_counts + @split_categories = @import.split_categories + @has_split_transactions = @import.has_split_transactions? + end + + def update + # If the user changed the date format, re-generate rows with the new format. + format_changed = false + if selection_params[:date_format].present? && selection_params[:date_format] != @import.qif_date_format + format_changed = true + @import.qif_date_format = selection_params[:date_format] + @import.update_column(:column_mappings, @import.column_mappings) + @import.generate_rows_from_csv + @import.sync_mappings + end + + all_categories = @import.row_categories + all_tags = @import.row_tags + + selected_categories = Array(selection_params[:categories]).reject(&:blank?) + selected_tags = Array(selection_params[:tags]).reject(&:blank?) + + deselected_categories = all_categories - selected_categories + deselected_tags = all_tags - selected_tags + + ActiveRecord::Base.transaction do + # Clear category on rows whose category was deselected + if deselected_categories.any? + @import.rows.where(category: deselected_categories).update_all(category: "") + end + + # Strip deselected tags from any row that carries them + if deselected_tags.any? + @import.rows.where.not(tags: [ nil, "" ]).find_each do |row| + remaining = row.tags_list - deselected_tags + remaining.reject!(&:blank?) + updated_tags = remaining.join("|") + row.update_column(:tags, updated_tags) if updated_tags != row.tags.to_s + end + end + + @import.sync_mappings unless format_changed + end + + redirect_to import_clean_path(@import), notice: "Categories and tags saved." + end + + private + + def set_import + @import = Current.family.imports.find(params[:import_id]) + + unless @import.is_a?(QifImport) + redirect_to imports_path and return + end + end + + def compute_tag_counts + counts = Hash.new(0) + @import.rows.each do |row| + row.tags_list.each { |tag| counts[tag] += 1 unless tag.blank? } + end + counts + end + + def selection_params + params.permit(:date_format, categories: [], tags: []) + end +end diff --git a/app/controllers/import/uploads_controller.rb b/app/controllers/import/uploads_controller.rb index a9a185d51..0212bc850 100644 --- a/app/controllers/import/uploads_controller.rb +++ b/app/controllers/import/uploads_controller.rb @@ -14,24 +14,80 @@ class Import::UploadsController < ApplicationController end def update - if csv_valid?(csv_str) - @import.account = Current.family.accounts.find_by(id: params.dig(:import, :account_id)) + if @import.is_a?(QifImport) + handle_qif_upload + elsif @import.is_a?(SureImport) + update_sure_import_upload + elsif csv_valid?(csv_str) + @import.account = import_account_id.present? ? accessible_accounts.find(import_account_id) : nil @import.assign_attributes(raw_file_str: csv_str, col_sep: upload_params[:col_sep]) @import.save!(validate: false) - redirect_to import_configuration_path(@import, template_hint: true), notice: "CSV uploaded successfully." + redirect_to import_configuration_path(@import, template_hint: true), notice: t("imports.create.csv_uploaded") else - flash.now[:alert] = "Must be valid CSV with headers and at least one row of data" + flash.now[:alert] = t("import.uploads.show.csv_invalid", default: "Must be valid CSV with headers and at least one row of data") render :show, status: :unprocessable_entity end end private + + def update_sure_import_upload + uploaded = upload_params[:ndjson_file] + unless uploaded.present? + flash.now[:alert] = t("import.uploads.sure_import.ndjson_invalid", default: "Must be valid NDJSON with at least one record") + render :show, status: :unprocessable_entity + return + end + + if uploaded.size > SureImport::MAX_NDJSON_SIZE + flash.now[:alert] = t("imports.create.file_too_large", max_size: SureImport::MAX_NDJSON_SIZE / 1.megabyte) + render :show, status: :unprocessable_entity + return + end + + content = uploaded.read + uploaded.rewind + + if ndjson_valid?(content) + uploaded.rewind + @import.ndjson_file.attach(uploaded) + @import.sync_ndjson_rows_count! + redirect_to import_path(@import), notice: t("imports.create.ndjson_uploaded") + else + flash.now[:alert] = t("import.uploads.sure_import.ndjson_invalid", default: "Must be valid NDJSON with at least one record") + + render :show, status: :unprocessable_entity + end + end + def set_import @import = Current.family.imports.find(params[:import_id]) end + def handle_qif_upload + unless QifParser.valid?(csv_str) + flash.now[:alert] = "Must be a valid QIF file" + render :show, status: :unprocessable_entity and return + end + + unless import_account_id.present? + flash.now[:alert] = "Please select an account for the QIF import" + render :show, status: :unprocessable_entity and return + end + + ActiveRecord::Base.transaction do + @import.account = accessible_accounts.find(import_account_id) + @import.raw_file_str = QifParser.normalize_encoding(csv_str) + @import.save!(validate: false) + @import.generate_rows_from_csv + @import.sync_mappings + end + + redirect_to import_qif_category_selection_path(@import), notice: "QIF file uploaded successfully." + end + def csv_str @csv_str ||= upload_params[:import_file]&.read || upload_params[:raw_file_str] end @@ -47,7 +103,26 @@ class Import::UploadsController < ApplicationController end end + def ndjson_valid?(str) + return false if str.blank? + + # Check at least first line is valid NDJSON + first_line = str.lines.first&.strip + return false if first_line.blank? + + begin + record = JSON.parse(first_line) + record.key?("type") && record.key?("data") + rescue JSON::ParserError + false + end + end + def upload_params - params.require(:import).permit(:raw_file_str, :import_file, :col_sep) + params.require(:import).permit(:raw_file_str, :import_file, :ndjson_file, :col_sep) + end + + def import_account_id + params.require(:import).permit(:account_id)[:account_id] end end diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb index ef5f4b067..4a3cc0492 100644 --- a/app/controllers/imports_controller.rb +++ b/app/controllers/imports_controller.rb @@ -8,7 +8,7 @@ class ImportsController < ApplicationController account_id = params.dig(:pdf_import, :account_id) || params.dig(:import, :account_id) if account_id.present? - account = Current.family.accounts.find_by(id: account_id) + account = accessible_accounts.find_by(id: account_id) unless account redirect_back_or_to import_path(@import), alert: t("imports.update.invalid_account", default: "Account not found.") return @@ -49,6 +49,11 @@ class ImportsController < ApplicationController return end + if file.present? && sure_import_request? + create_sure_import(file) + return + end + # Handle PDF file uploads - process with AI if file.present? && Import::ALLOWED_PDF_MIME_TYPES.include?(file.content_type) unless valid_pdf_file?(file) @@ -62,7 +67,7 @@ class ImportsController < ApplicationController type = params.dig(:import, :type).to_s type = "TransactionImport" unless Import::TYPES.include?(type) - account = Current.family.accounts.find_by(id: params.dig(:import, :account_id)) + account = accessible_accounts.find_by(id: params.dig(:import, :account_id)) import = Current.family.imports.create!( type: type, account: account, @@ -85,6 +90,7 @@ class ImportsController < ApplicationController # Stream reading is not fully applicable here as we store the raw string in the DB, # but we have validated size beforehand to prevent memory exhaustion from massive files. import.update!(raw_file_str: file.read) + redirect_to import_configuration_path(import), notice: t("imports.create.csv_uploaded") else redirect_to import_upload_path(import) @@ -92,7 +98,10 @@ class ImportsController < ApplicationController end def show - return unless @import.requires_csv_workflow? + unless @import.requires_csv_workflow? + redirect_to import_upload_path(@import), alert: t("imports.show.finalize_upload") unless @import.uploaded? + return + end if !@import.uploaded? redirect_to import_upload_path(@import), alert: t("imports.show.finalize_upload") @@ -197,6 +206,40 @@ class ImportsController < ApplicationController params.dig(:import, :type) == "DocumentImport" end + def sure_import_request? + params.dig(:import, :type) == "SureImport" + end + + def create_sure_import(file) + if file.size > SureImport::MAX_NDJSON_SIZE + redirect_to new_import_path, alert: t("imports.create.file_too_large", max_size: SureImport::MAX_NDJSON_SIZE / 1.megabyte) + return + end + + ext = File.extname(file.original_filename.to_s).downcase + unless ext.in?(%w[.ndjson .json]) + redirect_to new_import_path, alert: t("imports.create.invalid_ndjson_file_type") + return + end + + content = file.read + file.rewind + unless SureImport.valid_ndjson_first_line?(content) + redirect_to new_import_path, alert: t("imports.create.invalid_ndjson_file_type") + return + end + + import = Current.family.imports.create!(type: "SureImport") + import.ndjson_file.attach( + io: StringIO.new(content), + filename: file.original_filename, + content_type: file.content_type + ) + import.sync_ndjson_rows_count! + + redirect_to import_path(import), notice: t("imports.create.ndjson_uploaded") + end + def valid_pdf_file?(file) header = file.read(5) file.rewind diff --git a/app/controllers/indexa_capital_items_controller.rb b/app/controllers/indexa_capital_items_controller.rb index ddc4145e8..4b37eea8a 100644 --- a/app/controllers/indexa_capital_items_controller.rb +++ b/app/controllers/indexa_capital_items_controller.rb @@ -4,6 +4,7 @@ class IndexaCapitalItemsController < ApplicationController ALLOWED_ACCOUNTABLE_TYPES = %w[Depository CreditCard Investment Loan OtherAsset OtherLiability Crypto Property Vehicle].freeze before_action :set_indexa_capital_item, only: [ :show, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ] + before_action :require_admin!, only: [ :new, :create, :preload_accounts, :select_accounts, :link_accounts, :select_existing_account, :link_existing_account, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ] def index @indexa_capital_items = Current.family.indexa_capital_items.ordered @@ -290,6 +291,7 @@ class IndexaCapitalItemsController < ApplicationController accountable: accountable_class.new ) + account.auto_share_with_family! if Current.family.share_all_by_default? indexa_capital_account.ensure_account_provider!(account) account end @@ -303,12 +305,15 @@ class IndexaCapitalItemsController < ApplicationController accountable_attrs[:subtype] = config[:subtype] end - Current.family.accounts.create!( + account = Current.family.accounts.create!( name: indexa_capital_account.name, balance: config[:balance].present? ? config[:balance].to_d : (indexa_capital_account.current_balance || 0), currency: indexa_capital_account.currency || "EUR", accountable: accountable_class.new(accountable_attrs) ) + + account.auto_share_with_family! if Current.family.share_all_by_default? + account end def infer_accountable_type(account_type, subtype = nil) diff --git a/app/controllers/investments_controller.rb b/app/controllers/investments_controller.rb index 1ef7d144b..5fa25f123 100644 --- a/app/controllers/investments_controller.rb +++ b/app/controllers/investments_controller.rb @@ -1,3 +1,5 @@ class InvestmentsController < ApplicationController include AccountableResource + + permitted_accountable_attributes :id, :subtype end diff --git a/app/controllers/invite_codes_controller.rb b/app/controllers/invite_codes_controller.rb index e97cb6ec0..f9bcf6760 100644 --- a/app/controllers/invite_codes_controller.rb +++ b/app/controllers/invite_codes_controller.rb @@ -1,12 +1,12 @@ class InviteCodesController < ApplicationController before_action :ensure_self_hosted + before_action :ensure_super_admin def index @invite_codes = InviteCode.all end def create - raise StandardError, "You are not allowed to generate invite codes" unless Current.user.admin? InviteCode.generate! redirect_back_or_to invite_codes_path, notice: "Code generated" end @@ -22,4 +22,8 @@ class InviteCodesController < ApplicationController def ensure_self_hosted redirect_to root_path unless self_hosted? end + + def ensure_super_admin + redirect_to root_path, alert: t("settings.hostings.not_authorized") unless Current.user.super_admin? + end end diff --git a/app/controllers/lunchflow_items_controller.rb b/app/controllers/lunchflow_items_controller.rb index 2208090e7..0fe018a55 100644 --- a/app/controllers/lunchflow_items_controller.rb +++ b/app/controllers/lunchflow_items_controller.rb @@ -1,5 +1,6 @@ class LunchflowItemsController < ApplicationController before_action :set_lunchflow_item, only: [ :show, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ] + before_action :require_admin!, only: [ :new, :create, :preload_accounts, :select_accounts, :link_accounts, :select_existing_account, :link_existing_account, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ] def index @lunchflow_items = Current.family.lunchflow_items.active.ordered @@ -534,7 +535,7 @@ class LunchflowItemsController < ApplicationController # Helper to translate subtype options translate_subtypes = ->(type_key, subtypes_hash) { - subtypes_hash.keys.map { |k| [ t(".subtypes.#{type_key}.#{k}"), k ] } + subtypes_hash.map { |k, v| [ t(".subtypes.#{type_key}.#{k}", default: v[:long] || k.humanize), k ] } } # Subtype options for each account type (only include supported types) diff --git a/app/controllers/mercury_items_controller.rb b/app/controllers/mercury_items_controller.rb index 0c72e3cd6..14e34ca0f 100644 --- a/app/controllers/mercury_items_controller.rb +++ b/app/controllers/mercury_items_controller.rb @@ -1,5 +1,6 @@ class MercuryItemsController < ApplicationController before_action :set_mercury_item, only: [ :show, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ] + before_action :require_admin!, only: [ :new, :create, :preload_accounts, :select_accounts, :link_accounts, :select_existing_account, :link_existing_account, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ] def index @mercury_items = Current.family.mercury_items.active.ordered @@ -538,7 +539,7 @@ class MercuryItemsController < ApplicationController # Helper to translate subtype options translate_subtypes = ->(type_key, subtypes_hash) { - subtypes_hash.keys.map { |k| [ t(".subtypes.#{type_key}.#{k}"), k ] } + subtypes_hash.map { |k, v| [ t(".subtypes.#{type_key}.#{k}", default: v[:long] || k.humanize), k ] } } # Subtype options for each account type (only include supported types) diff --git a/app/controllers/oidc_accounts_controller.rb b/app/controllers/oidc_accounts_controller.rb index cd46bf30e..25548995d 100644 --- a/app/controllers/oidc_accounts_controller.rb +++ b/app/controllers/oidc_accounts_controller.rb @@ -14,9 +14,12 @@ class OidcAccountsController < ApplicationController @email = @pending_auth["email"] @user_exists = User.exists?(email: @email) if @email.present? + # Check for a pending invitation for this email + @pending_invitation = Invitation.pending.find_by(email: @email) if @email.present? + # Determine whether we should offer JIT account creation for this # pending auth, based on JIT mode and allowed domains. - @allow_account_creation = !AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(@email) + @allow_account_creation = @pending_invitation.present? || (!AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(@email)) end def create_link @@ -94,10 +97,13 @@ class OidcAccountsController < ApplicationController email = @pending_auth["email"] + # Check for a pending invitation for this email + invitation = Invitation.pending.find_by(email: email) + # Respect global JIT configuration: in link_only mode or when the email - # domain is not allowed, block JIT account creation and send the user - # back to the login page with a clear message. - unless !AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(email) + # domain is not allowed, block JIT account creation—unless there's a + # pending invitation for this user. + unless invitation.present? || (!AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(email)) redirect_to new_session_path, alert: "SSO account creation is disabled. Please contact an administrator." return end @@ -115,14 +121,20 @@ class OidcAccountsController < ApplicationController skip_password_validation: true ) - # Create new family for this user - @user.family = Family.new + if invitation.present? + # Accept the pending invitation: join the existing family + @user.family_id = invitation.family_id + @user.role = invitation.role + else + # Create new family for this user + @user.family = Family.new - # Use provider-configured default role, or fall back to admin for family creators - # First user of an instance always becomes super_admin regardless of provider config - provider_config = Rails.configuration.x.auth.sso_providers&.find { |p| p[:name] == @pending_auth["provider"] } - provider_default_role = provider_config&.dig(:settings, :default_role) - @user.role = User.role_for_new_family_creator(fallback_role: provider_default_role || :admin) + # Use provider-configured default role, or fall back to admin for family creators + # First user of an instance always becomes super_admin regardless of provider config + provider_config = Rails.configuration.x.auth.sso_providers&.find { |p| p[:name] == @pending_auth["provider"] } + provider_default_role = provider_config&.dig(:settings, :default_role) + @user.role = User.role_for_new_family_creator(fallback_role: provider_default_role || :admin) + end if @user.save # Create the OIDC (or other SSO) identity @@ -140,11 +152,20 @@ class OidcAccountsController < ApplicationController ) end + # Mark invitation as accepted if one was used + invitation&.update!(accepted_at: Time.current) + # Clear pending auth from session session.delete(:pending_oidc_auth) @session = create_session_for(@user) - notice = accept_pending_invitation_for(@user) ? t("invitations.accept_choice.joined_household") : "Welcome! Your account has been created." + notice = if invitation.present? + t("invitations.accept_choice.joined_household") + elsif accept_pending_invitation_for(@user) + t("invitations.accept_choice.joined_household") + else + "Welcome! Your account has been created." + end redirect_to root_path, notice: notice else render :new_user, status: :unprocessable_entity diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 7f2aa6785..e0802c1c5 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -11,16 +11,18 @@ class PagesController < ApplicationController @balance_sheet = Current.family.balance_sheet @investment_statement = Current.family.investment_statement - @accounts = Current.family.accounts.visible.with_attached_logo + @accounts = Current.user.accessible_accounts.visible.with_attached_logo family_currency = Current.family.currency # Use IncomeStatement for all cashflow data (now includes categorized trades) - income_totals = Current.family.income_statement.income_totals(period: @period) - expense_totals = Current.family.income_statement.expense_totals(period: @period) + income_statement = Current.family.income_statement + income_totals = income_statement.income_totals(period: @period) + expense_totals = income_statement.expense_totals(period: @period) + net_totals = income_statement.net_category_totals(period: @period) - @cashflow_sankey_data = build_cashflow_sankey_data(income_totals, expense_totals, family_currency) - @outflows_data = build_outflows_donut_data(expense_totals) + @cashflow_sankey_data = build_cashflow_sankey_data(net_totals, income_totals, expense_totals, family_currency) + @outflows_data = build_outflows_donut_data(net_totals) @dashboard_sections = build_dashboard_sections @@ -88,7 +90,7 @@ class PagesController < ApplicationController title: "pages.dashboard.cashflow_sankey.title", partial: "pages/dashboard/cashflow_sankey", locals: { sankey_data: @cashflow_sankey_data, period: @period }, - visible: Current.family.accounts.any?, + visible: @accounts.any?, collapsible: true }, { @@ -96,7 +98,7 @@ class PagesController < ApplicationController title: "pages.dashboard.outflows_donut.title", partial: "pages/dashboard/outflows_donut", locals: { outflows_data: @outflows_data, period: @period }, - visible: Current.family.accounts.any? && @outflows_data[:categories].present?, + visible: @accounts.any? && @outflows_data[:categories].present?, collapsible: true }, { @@ -104,7 +106,7 @@ class PagesController < ApplicationController title: "pages.dashboard.investment_summary.title", partial: "pages/dashboard/investment_summary", locals: { investment_statement: @investment_statement, period: @period }, - visible: Current.family.accounts.any? && @investment_statement.investment_accounts.any?, + visible: @accounts.any? && @investment_statement.investment_accounts.any?, collapsible: true }, { @@ -112,7 +114,7 @@ class PagesController < ApplicationController title: "pages.dashboard.net_worth_chart.title", partial: "pages/dashboard/net_worth_chart", locals: { balance_sheet: @balance_sheet, period: @period }, - visible: Current.family.accounts.any?, + visible: @accounts.any?, collapsible: true }, { @@ -120,7 +122,7 @@ class PagesController < ApplicationController title: "pages.dashboard.balance_sheet.title", partial: "pages/dashboard/balance_sheet", locals: { balance_sheet: @balance_sheet }, - visible: Current.family.accounts.any?, + visible: @accounts.any?, collapsible: true } ] @@ -143,7 +145,7 @@ class PagesController < ApplicationController Provider::Registry.get_provider(:github) end - def build_cashflow_sankey_data(income_totals, expense_totals, currency) + def build_cashflow_sankey_data(net_totals, income_totals, expense_totals, currency) nodes = [] links = [] node_indices = {} @@ -155,30 +157,33 @@ class PagesController < ApplicationController end } - total_income = income_totals.total.to_f.round(2) - total_expense = expense_totals.total.to_f.round(2) + total_income = net_totals.total_net_income.to_f.round(2) + total_expense = net_totals.total_net_expense.to_f.round(2) # Central Cash Flow node cash_flow_idx = add_node.call("cash_flow_node", "Cash Flow", total_income, 100.0, "var(--color-success)") - # Process income categories (flow: subcategory -> parent -> cash_flow) - process_category_totals( - category_totals: income_totals.category_totals, + # Build netted subcategory data from raw totals + net_subcategories_by_parent = build_net_subcategories(expense_totals, income_totals) + + # Process net income categories (flow: subcategory -> parent -> cash_flow) + process_net_category_nodes( + categories: net_totals.net_income_categories, total: total_income, prefix: "income", - default_color: Category::UNCATEGORIZED_COLOR, + net_subcategories_by_parent: net_subcategories_by_parent, add_node: add_node, links: links, cash_flow_idx: cash_flow_idx, flow_direction: :inbound ) - # Process expense categories (flow: cash_flow -> parent -> subcategory) - process_category_totals( - category_totals: expense_totals.category_totals, + # Process net expense categories (flow: cash_flow -> parent -> subcategory) + process_net_category_nodes( + categories: net_totals.net_expense_categories, total: total_expense, prefix: "expense", - default_color: Category::UNCATEGORIZED_COLOR, + net_subcategories_by_parent: net_subcategories_by_parent, add_node: add_node, links: links, cash_flow_idx: cash_flow_idx, @@ -196,12 +201,124 @@ class PagesController < ApplicationController { nodes: nodes, links: links, currency_symbol: Money::Currency.new(currency).symbol } end - def build_outflows_donut_data(expense_totals) - currency_symbol = Money::Currency.new(expense_totals.currency).symbol - total = expense_totals.total + # Nets subcategory expense and income totals, grouped by parent_id. + # Returns { parent_id => [ { category:, total: net_amount }, ... ] } + # Only includes subcategories with positive net (same direction as parent). + def build_net_subcategories(expense_totals, income_totals) + expense_subs = expense_totals.category_totals + .select { |ct| ct.category.parent_id.present? } + .index_by { |ct| ct.category.id } - categories = expense_totals.category_totals - .reject { |ct| ct.category.parent_id.present? || ct.total.zero? } + income_subs = income_totals.category_totals + .select { |ct| ct.category.parent_id.present? } + .index_by { |ct| ct.category.id } + + all_sub_ids = (expense_subs.keys + income_subs.keys).uniq + result = {} + + all_sub_ids.each do |sub_id| + exp_ct = expense_subs[sub_id] + inc_ct = income_subs[sub_id] + exp_total = exp_ct&.total || 0 + inc_total = inc_ct&.total || 0 + net = exp_total - inc_total + category = exp_ct&.category || inc_ct&.category + + next if net.zero? + + parent_id = category.parent_id + result[parent_id] ||= [] + result[parent_id] << { category: category, total: net.abs, net_direction: net > 0 ? :expense : :income } + end + + result + end + + # Builds sankey nodes/links for net categories with subcategory hierarchy. + # Subcategories matching the parent's flow direction are shown as children. + # Subcategories with opposite net direction appear on the OTHER side of the + # sankey (handled when the other side calls this method). + # + # flow_direction: :inbound (subcategory -> parent -> cash_flow) for income + # :outbound (cash_flow -> parent -> subcategory) for expenses + def process_net_category_nodes(categories:, total:, prefix:, net_subcategories_by_parent:, add_node:, links:, cash_flow_idx:, flow_direction:) + matching_direction = flow_direction == :inbound ? :income : :expense + + categories.each do |ct| + val = ct.total.to_f.round(2) + next if val.zero? + + percentage = total.zero? ? 0 : (val / total * 100).round(1) + color = ct.category.color.presence || Category::UNCATEGORIZED_COLOR + node_key = "#{prefix}_#{ct.category.id || ct.category.name}" + + all_subs = ct.category.id ? (net_subcategories_by_parent[ct.category.id] || []) : [] + same_side_subs = all_subs.select { |s| s[:net_direction] == matching_direction } + + # Also check if any subcategory has opposite direction — those will be + # rendered by the OTHER side's call to this method, linked to cash_flow + # directly (they appear as independent nodes on the opposite side). + opposite_subs = all_subs.select { |s| s[:net_direction] != matching_direction } + + if same_side_subs.any? + parent_idx = add_node.call(node_key, ct.category.name, val, percentage, color) + + if flow_direction == :inbound + links << { source: parent_idx, target: cash_flow_idx, value: val, color: color, percentage: percentage } + else + links << { source: cash_flow_idx, target: parent_idx, value: val, color: color, percentage: percentage } + end + + same_side_subs.each do |sub| + sub_val = sub[:total].to_f.round(2) + sub_pct = val.zero? ? 0 : (sub_val / val * 100).round(1) + sub_color = sub[:category].color.presence || color + sub_key = "#{prefix}_sub_#{sub[:category].id}" + sub_idx = add_node.call(sub_key, sub[:category].name, sub_val, sub_pct, sub_color) + + if flow_direction == :inbound + links << { source: sub_idx, target: parent_idx, value: sub_val, color: sub_color, percentage: sub_pct } + else + links << { source: parent_idx, target: sub_idx, value: sub_val, color: sub_color, percentage: sub_pct } + end + end + else + idx = add_node.call(node_key, ct.category.name, val, percentage, color) + + if flow_direction == :inbound + links << { source: idx, target: cash_flow_idx, value: val, color: color, percentage: percentage } + else + links << { source: cash_flow_idx, target: idx, value: val, color: color, percentage: percentage } + end + end + + # Render opposite-direction subcategories as standalone nodes on this side, + # linked directly to cash_flow. They represent subcategory surplus/deficit + # that goes against the parent's overall direction. + opposite_prefix = flow_direction == :inbound ? "expense" : "income" + opposite_subs.each do |sub| + sub_val = sub[:total].to_f.round(2) + sub_pct = total.zero? ? 0 : (sub_val / total * 100).round(1) + sub_color = sub[:category].color.presence || color + sub_key = "#{opposite_prefix}_sub_#{sub[:category].id}" + sub_idx = add_node.call(sub_key, sub[:category].name, sub_val, sub_pct, sub_color) + + # Opposite direction: if parent is outbound (expense), this sub is inbound (income) + if flow_direction == :inbound + links << { source: cash_flow_idx, target: sub_idx, value: sub_val, color: sub_color, percentage: sub_pct } + else + links << { source: sub_idx, target: cash_flow_idx, value: sub_val, color: sub_color, percentage: sub_pct } + end + end + end + end + + def build_outflows_donut_data(net_totals) + currency_symbol = Money::Currency.new(net_totals.currency).symbol + total = net_totals.total_net_expense + + categories = net_totals.net_expense_categories + .reject { |ct| ct.total.zero? } .sort_by { |ct| -ct.total } .map do |ct| { @@ -216,66 +333,7 @@ class PagesController < ApplicationController } end - { categories: categories, total: total.to_f.round(2), currency: expense_totals.currency, currency_symbol: currency_symbol } - end - - # Processes category totals for sankey diagram, handling parent/subcategory relationships. - # flow_direction: :inbound (subcategory -> parent -> cash_flow) for income - # :outbound (cash_flow -> parent -> subcategory) for expenses - def process_category_totals(category_totals:, total:, prefix:, default_color:, add_node:, links:, cash_flow_idx:, flow_direction:) - # Build lookup of subcategories by parent_id - subcategories_by_parent = category_totals - .select { |ct| ct.category.parent_id.present? && ct.total.to_f > 0 } - .group_by { |ct| ct.category.parent_id } - - category_totals.each do |ct| - next if ct.category.parent_id.present? # Skip subcategories in first pass - - val = ct.total.to_f.round(2) - next if val.zero? - - percentage = total.zero? ? 0 : (val / total * 100).round(1) - color = ct.category.color.presence || default_color - node_key = "#{prefix}_#{ct.category.id || ct.category.name}" - - subs = subcategories_by_parent[ct.category.id] || [] - - if subs.any? - parent_idx = add_node.call(node_key, ct.category.name, val, percentage, color) - - # Link parent to/from cash flow based on direction - if flow_direction == :inbound - links << { source: parent_idx, target: cash_flow_idx, value: val, color: color, percentage: percentage } - else - links << { source: cash_flow_idx, target: parent_idx, value: val, color: color, percentage: percentage } - end - - # Add subcategory nodes - subs.each do |sub_ct| - sub_val = sub_ct.total.to_f.round(2) - sub_pct = val.zero? ? 0 : (sub_val / val * 100).round(1) - sub_color = sub_ct.category.color.presence || color - sub_key = "#{prefix}_sub_#{sub_ct.category.id}" - sub_idx = add_node.call(sub_key, sub_ct.category.name, sub_val, sub_pct, sub_color) - - # Link subcategory to/from parent based on direction - if flow_direction == :inbound - links << { source: sub_idx, target: parent_idx, value: sub_val, color: sub_color, percentage: sub_pct } - else - links << { source: parent_idx, target: sub_idx, value: sub_val, color: sub_color, percentage: sub_pct } - end - end - else - # No subcategories, link directly to/from cash flow - idx = add_node.call(node_key, ct.category.name, val, percentage, color) - - if flow_direction == :inbound - links << { source: idx, target: cash_flow_idx, value: val, color: color, percentage: percentage } - else - links << { source: cash_flow_idx, target: idx, value: val, color: color, percentage: percentage } - end - end - end + { categories: categories, total: total.to_f.round(2), currency: net_totals.currency, currency_symbol: currency_symbol } end def ensure_intro_guest! diff --git a/app/controllers/pending_duplicate_merges_controller.rb b/app/controllers/pending_duplicate_merges_controller.rb new file mode 100644 index 000000000..80c592f3b --- /dev/null +++ b/app/controllers/pending_duplicate_merges_controller.rb @@ -0,0 +1,84 @@ +class PendingDuplicateMergesController < ApplicationController + before_action :set_transaction + + def new + @limit = 10 + # Ensure offset is non-negative to prevent abuse + @offset = [ (params[:offset] || 0).to_i, 0 ].max + + # Fetch one extra to determine if there are more results + candidates = @transaction.pending_duplicate_candidates(limit: @limit + 1, offset: @offset).to_a + @has_more = candidates.size > @limit + @potential_duplicates = candidates.first(@limit) + + # Calculate range for display (e.g., "1-10", "11-20") + @range_start = @offset + 1 + @range_end = @offset + @potential_duplicates.count + end + + def create + return unless require_account_permission!(@transaction.entry.account, :annotate, redirect_path: transactions_path) + + # Manually merge the pending transaction with the selected posted transaction + unless merge_params[:posted_entry_id].present? + redirect_back_or_to transactions_path, alert: "Please select a posted transaction to merge with" + return + end + + # Validate the posted entry is an eligible candidate (same account, currency, not pending) + posted_entry = find_eligible_posted_entry(merge_params[:posted_entry_id]) + + unless posted_entry + redirect_back_or_to transactions_path, alert: "Invalid transaction selected for merge" + return + end + + # Store the merge suggestion and immediately execute it + @transaction.update!( + extra: (@transaction.extra || {}).merge( + "potential_posted_match" => { + "entry_id" => posted_entry.id, + "reason" => "manual_match", + "posted_amount" => posted_entry.amount.to_s, + "confidence" => "high", # Manual matches are high confidence + "detected_at" => Date.current.to_s + } + ) + ) + + # Immediately merge + if @transaction.merge_with_duplicate! + redirect_back_or_to transactions_path, notice: "Pending transaction merged with posted transaction" + else + redirect_back_or_to transactions_path, alert: "Could not merge transactions" + end + end + + private + def set_transaction + entry = Current.accessible_entries.find(params[:transaction_id]) + @transaction = entry.entryable + + unless @transaction.is_a?(Transaction) && @transaction.pending? + redirect_to transactions_path, alert: "This feature is only available for pending transactions" + end + end + + def find_eligible_posted_entry(entry_id) + # Constrain to same account, currency, and ensure it's a posted transaction + # Use the same logic as pending_duplicate_candidates to ensure consistency + conditions = Transaction::PENDING_PROVIDERS.map { |provider| "(transactions.extra -> '#{provider}' ->> 'pending')::boolean IS NOT TRUE" } + + @transaction.entry.account.entries + .joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'") + .where(id: entry_id) + .where(currency: @transaction.entry.currency) + .where.not(id: @transaction.entry.id) + .where(conditions.join(" AND ")) + .first + end + + def merge_params + params.require(:pending_duplicate_merges).permit(:posted_entry_id) + end +end diff --git a/app/controllers/plaid_items_controller.rb b/app/controllers/plaid_items_controller.rb index f2846d04e..bfe8c5bd5 100644 --- a/app/controllers/plaid_items_controller.rb +++ b/app/controllers/plaid_items_controller.rb @@ -1,5 +1,6 @@ class PlaidItemsController < ApplicationController before_action :set_plaid_item, only: %i[edit destroy sync] + before_action :require_admin!, only: %i[new create select_existing_account link_existing_account edit destroy sync] def new region = params[:region] == "eu" ? :eu : :us diff --git a/app/controllers/properties_controller.rb b/app/controllers/properties_controller.rb index c222d5a74..e937c4fb6 100644 --- a/app/controllers/properties_controller.rb +++ b/app/controllers/properties_controller.rb @@ -2,6 +2,7 @@ class PropertiesController < ApplicationController include AccountableResource, StreamExtensions before_action :set_property, only: [ :balances, :address, :update_balances, :update_address ] + before_action :require_property_write_permission!, only: [ :update_balances, :update_address ] def new @account = Current.family.accounts.build(accountable: Property.new) @@ -9,8 +10,9 @@ class PropertiesController < ApplicationController def create @account = Current.family.accounts.create!( - property_params.merge(currency: Current.family.currency, balance: 0, status: "draft") + property_params.merge(currency: Current.family.currency, balance: 0, status: "draft", owner: Current.user) ) + @account.auto_share_with_family! if Current.family.share_all_by_default? redirect_to balances_property_path(@account) end @@ -100,7 +102,11 @@ class PropertiesController < ApplicationController end def set_property - @account = Current.family.accounts.find(params[:id]) + @account = accessible_accounts.find(params[:id]) @property = @account.property end + + def require_property_write_permission! + require_account_permission!(@account) + end end diff --git a/app/controllers/recurring_transactions_controller.rb b/app/controllers/recurring_transactions_controller.rb index 0f06b52fb..723e166d8 100644 --- a/app/controllers/recurring_transactions_controller.rb +++ b/app/controllers/recurring_transactions_controller.rb @@ -3,6 +3,7 @@ class RecurringTransactionsController < ApplicationController def index @recurring_transactions = Current.family.recurring_transactions + .accessible_by(Current.user) .includes(:merchant) .order(status: :asc, next_expected_date: :asc) @family = Current.family @@ -42,7 +43,7 @@ class RecurringTransactionsController < ApplicationController end def toggle_status - @recurring_transaction = Current.family.recurring_transactions.find(params[:id]) + @recurring_transaction = Current.family.recurring_transactions.accessible_by(Current.user).find(params[:id]) if @recurring_transaction.active? @recurring_transaction.mark_inactive! @@ -61,7 +62,7 @@ class RecurringTransactionsController < ApplicationController end def destroy - @recurring_transaction = Current.family.recurring_transactions.find(params[:id]) + @recurring_transaction = Current.family.recurring_transactions.accessible_by(Current.user).find(params[:id]) @recurring_transaction.destroy! flash[:notice] = t("recurring_transactions.deleted") diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 93cc303bd..074f46cbd 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -18,6 +18,11 @@ class RegistrationsController < ApplicationController @user.family = @invitation.family @user.role = @invitation.role @user.email = @invitation.email + elsif (default_family_id = Setting.invite_only_default_family_id).present? && + Setting.onboarding_state == "invite_only" && + (default_family = Family.find_by(id: default_family_id)) + @user.family = default_family + @user.role = :member else family = Family.new @user.family = family diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb index d76a82c12..47202ec48 100644 --- a/app/controllers/reports_controller.rb +++ b/app/controllers/reports_controller.rb @@ -124,10 +124,10 @@ class ReportsController < ApplicationController @investment_metrics = build_investment_metrics # Investment flows (contributions/withdrawals) - @investment_flows = InvestmentFlowStatement.new(Current.family).period_totals(period: @period) + @investment_flows = InvestmentFlowStatement.new(Current.family, user: Current.user).period_totals(period: @period) # Flags for view rendering - @has_accounts = Current.family.accounts.any? + @has_accounts = accessible_accounts.any? end def preferences_params @@ -145,7 +145,7 @@ class ReportsController < ApplicationController title: "reports.net_worth.title", partial: "reports/net_worth", locals: { net_worth_metrics: @net_worth_metrics }, - visible: Current.family.accounts.any?, + visible: accessible_accounts.any?, collapsible: true }, { @@ -153,7 +153,7 @@ class ReportsController < ApplicationController title: "reports.trends.title", partial: "reports/trends_insights", locals: { trends_data: @trends_data }, - visible: Current.family.transactions.any?, + visible: @has_accounts, collapsible: true }, { @@ -182,7 +182,7 @@ class ReportsController < ApplicationController start_date: @start_date, end_date: @end_date }, - visible: Current.family.transactions.any?, + visible: @has_accounts, collapsible: true } ] @@ -300,7 +300,7 @@ class ReportsController < ApplicationController # Only calculate if we're looking at current month return nil unless @period_type == :monthly && @start_date.beginning_of_month.to_date == Date.current.beginning_of_month.to_date - budget = Budget.find_or_bootstrap(Current.family, start_date: @start_date.beginning_of_month.to_date) + budget = Budget.find_or_bootstrap(Current.family, start_date: @start_date.beginning_of_month.to_date, user: Current.user) return 0 if budget.nil? || budget.allocated_spending.zero? (budget.actual_spending / budget.allocated_spending * 100).round(1) @@ -353,7 +353,7 @@ class ReportsController < ApplicationController .where.not(kind: Transaction::BUDGET_EXCLUDED_KINDS) .includes(entry: :account, category: :parent) - # Apply filters + # Apply filters (includes finance account scoping) transactions = apply_transaction_filters(transactions) # Get trades in the period (matching income_statement logic) @@ -364,6 +364,8 @@ class ReportsController < ApplicationController .where(entries: { entryable_type: "Trade", excluded: false, date: @period.date_range }) .includes(entry: :account, category: :parent) + trades = apply_entry_filters(trades) + # Get sort parameters sort_by = params[:sort_by] || "amount" sort_direction = params[:sort_direction] || "desc" @@ -558,49 +560,60 @@ class ReportsController < ApplicationController } end - def apply_transaction_filters(transactions) + def apply_transaction_filters(scope) + scope = apply_entry_filters(scope) + + # Filter by tag (Transaction-specific — trades don't have taggings) + if params[:filter_tag_id].present? + scope = scope.joins(:taggings).where(taggings: { tag_id: params[:filter_tag_id] }) + end + + scope + end + + # Filters applicable to both transactions and trades (entry-level + category) + def apply_entry_filters(scope) + # Scope to user's finance accounts + finance_account_ids = Current.user&.finance_accounts&.pluck(:id) || [] + scope = scope.where(entries: { account_id: finance_account_ids }) + # Filter by category (including subcategories) if params[:filter_category_id].present? category_id = params[:filter_category_id] # Scope to family's categories to prevent cross-family data access subcategory_ids = Current.family.categories.where(parent_id: category_id).pluck(:id) all_category_ids = [ category_id ] + subcategory_ids - transactions = transactions.where(category_id: all_category_ids) + scope = scope.where(category_id: all_category_ids) end # Filter by account if params[:filter_account_id].present? - transactions = transactions.where(entries: { account_id: params[:filter_account_id] }) - end - - # Filter by tag - if params[:filter_tag_id].present? - transactions = transactions.joins(:taggings).where(taggings: { tag_id: params[:filter_tag_id] }) + scope = scope.where(entries: { account_id: params[:filter_account_id] }) end # Filter by amount range if params[:filter_amount_min].present? - transactions = transactions.where("ABS(entries.amount) >= ?", params[:filter_amount_min].to_f) + scope = scope.where("ABS(entries.amount) >= ?", params[:filter_amount_min].to_f) end if params[:filter_amount_max].present? - transactions = transactions.where("ABS(entries.amount) <= ?", params[:filter_amount_max].to_f) + scope = scope.where("ABS(entries.amount) <= ?", params[:filter_amount_max].to_f) end # Filter by date range (within the period) if params[:filter_date_start].present? filter_start = Date.parse(params[:filter_date_start]) - transactions = transactions.where("entries.date >= ?", filter_start) if filter_start >= @start_date + scope = scope.where("entries.date >= ?", filter_start) if filter_start >= @start_date end if params[:filter_date_end].present? filter_end = Date.parse(params[:filter_date_end]) - transactions = transactions.where("entries.date <= ?", filter_end) if filter_end <= @end_date + scope = scope.where("entries.date <= ?", filter_end) if filter_end <= @end_date end - transactions + scope rescue Date::Error - transactions + scope end def build_transactions_breakdown_for_export diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index ec53ef5f6..ea2f37c08 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -188,10 +188,10 @@ class SessionsController < ApplicationController redirect_to root_path end else - # Mobile SSO with no linked identity - redirect back with error + # Mobile SSO with no linked identity - cache pending auth and redirect + # back to the app with a linking code so the user can link or create an account if session[:mobile_sso].present? - session.delete(:mobile_sso) - mobile_sso_redirect(error: "account_not_linked", message: "Please link your Google account from the web app first") + handle_mobile_sso_onboarding(auth) return end @@ -273,6 +273,41 @@ class SessionsController < ApplicationController mobile_sso_redirect(error: "device_error", message: "Unable to register device") end + def handle_mobile_sso_onboarding(auth) + device_info = session.delete(:mobile_sso) + email = auth.info&.email + + has_pending_invitation = email.present? && Invitation.pending.exists?(email: email) + allow_creation = has_pending_invitation || (!AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(email)) + + linking_code = SecureRandom.urlsafe_base64(32) + Rails.cache.write( + "mobile_sso_link:#{linking_code}", + { + provider: auth.provider, + uid: auth.uid, + email: email, + first_name: auth.info&.first_name, + last_name: auth.info&.last_name, + name: auth.info&.name, + issuer: auth.extra&.raw_info&.iss || auth.extra&.raw_info&.[]("iss"), + device_info: device_info, + allow_account_creation: allow_creation + }, + expires_in: 10.minutes + ) + + mobile_sso_redirect( + status: "account_not_linked", + linking_code: linking_code, + email: email, + first_name: auth.info&.first_name, + last_name: auth.info&.last_name, + allow_account_creation: allow_creation, + has_pending_invitation: has_pending_invitation + ) + end + def mobile_sso_redirect(params = {}) redirect_to "sureapp://oauth/callback?#{params.to_query}", allow_other_host: true end diff --git a/app/controllers/settings/appearances_controller.rb b/app/controllers/settings/appearances_controller.rb new file mode 100644 index 000000000..51ae26739 --- /dev/null +++ b/app/controllers/settings/appearances_controller.rb @@ -0,0 +1,19 @@ +class Settings::AppearancesController < ApplicationController + layout "settings" + + def show + @user = Current.user + end + + def update + @user = Current.user + @user.transaction do + @user.lock! + updated_prefs = (@user.preferences || {}).deep_dup + updated_prefs["show_split_grouped"] = params.dig(:user, :show_split_grouped) == "1" if params.dig(:user, :show_split_grouped) + updated_prefs["dashboard_two_column"] = params.dig(:user, :dashboard_two_column) == "1" if params.dig(:user, :dashboard_two_column) + @user.update!(preferences: updated_prefs) + end + redirect_to settings_appearance_path + end +end diff --git a/app/controllers/settings/bank_sync_controller.rb b/app/controllers/settings/bank_sync_controller.rb index 0331e19c6..f954e5369 100644 --- a/app/controllers/settings/bank_sync_controller.rb +++ b/app/controllers/settings/bank_sync_controller.rb @@ -6,7 +6,7 @@ class Settings::BankSyncController < ApplicationController { name: "Lunch Flow", description: "US, Canada, UK, EU, Brazil and Asia through multiple open banking providers.", - path: "https://lunchflow.app/features/sure-integration", + path: "https://lunchflow.app/features/sure-integration?atp=BiDIYS", target: "_blank", rel: "noopener noreferrer" }, diff --git a/app/controllers/settings/hostings_controller.rb b/app/controllers/settings/hostings_controller.rb index 22936cfb4..f3a63e9a7 100644 --- a/app/controllers/settings/hostings_controller.rb +++ b/app/controllers/settings/hostings_controller.rb @@ -3,7 +3,8 @@ class Settings::HostingsController < ApplicationController guard_feature unless: -> { self_hosted? } - before_action :ensure_admin, only: [ :update, :clear_cache ] + before_action :ensure_admin, only: [ :update, :clear_cache, :disconnect_external_assistant ] + before_action :ensure_super_admin_for_onboarding, only: :update def show @breadcrumbs = [ @@ -43,6 +44,11 @@ class Settings::HostingsController < ApplicationController Setting.require_email_confirmation = hosting_params[:require_email_confirmation] end + if hosting_params.key?(:invite_only_default_family_id) + value = hosting_params[:invite_only_default_family_id].presence + Setting.invite_only_default_family_id = value + end + if hosting_params.key?(:brand_fetch_client_id) Setting.brand_fetch_client_id = hosting_params[:brand_fetch_client_id] end @@ -118,6 +124,23 @@ class Settings::HostingsController < ApplicationController Setting.openai_json_mode = hosting_params[:openai_json_mode].presence end + if hosting_params.key?(:external_assistant_url) + Setting.external_assistant_url = hosting_params[:external_assistant_url] + end + + if hosting_params.key?(:external_assistant_token) + token_param = hosting_params[:external_assistant_token].to_s.strip + unless token_param.blank? || token_param == "********" + Setting.external_assistant_token = token_param + end + end + + if hosting_params.key?(:external_assistant_agent_id) + Setting.external_assistant_agent_id = hosting_params[:external_assistant_agent_id] + end + + update_assistant_type + redirect_to settings_hosting_path, notice: t(".success") rescue Setting::ValidationError => error flash.now[:alert] = error.message @@ -129,15 +152,41 @@ class Settings::HostingsController < ApplicationController redirect_to settings_hosting_path, notice: t(".cache_cleared") end + def disconnect_external_assistant + Setting.external_assistant_url = nil + Setting.external_assistant_token = nil + Setting.external_assistant_agent_id = nil + Current.family.update!(assistant_type: "builtin") unless ENV["ASSISTANT_TYPE"].present? + redirect_to settings_hosting_path, notice: t(".external_assistant_disconnected") + rescue => e + Rails.logger.error("[External Assistant] Disconnect failed: #{e.message}") + redirect_to settings_hosting_path, alert: t("settings.hostings.update.failure") + end + private def hosting_params - params.require(:setting).permit(:onboarding_state, :require_email_confirmation, :brand_fetch_client_id, :brand_fetch_high_res_logos, :twelve_data_api_key, :openai_access_token, :openai_uri_base, :openai_model, :openai_json_mode, :exchange_rate_provider, :securities_provider, :syncs_include_pending, :auto_sync_enabled, :auto_sync_time) + return ActionController::Parameters.new unless params.key?(:setting) + params.require(:setting).permit(:onboarding_state, :require_email_confirmation, :invite_only_default_family_id, :brand_fetch_client_id, :brand_fetch_high_res_logos, :twelve_data_api_key, :openai_access_token, :openai_uri_base, :openai_model, :openai_json_mode, :exchange_rate_provider, :securities_provider, :syncs_include_pending, :auto_sync_enabled, :auto_sync_time, :external_assistant_url, :external_assistant_token, :external_assistant_agent_id) + end + + def update_assistant_type + return unless params[:family].present? && params[:family][:assistant_type].present? + return if ENV["ASSISTANT_TYPE"].present? + + assistant_type = params[:family][:assistant_type] + Current.family.update!(assistant_type: assistant_type) if Family::ASSISTANT_TYPES.include?(assistant_type) end def ensure_admin redirect_to settings_hosting_path, alert: t(".not_authorized") unless Current.user.admin? end + def ensure_super_admin_for_onboarding + onboarding_params = %i[onboarding_state invite_only_default_family_id] + return unless onboarding_params.any? { |p| hosting_params.key?(p) } + redirect_to settings_hosting_path, alert: t(".not_authorized") unless Current.user.super_admin? + end + def sync_auto_sync_scheduler! AutoSyncScheduler.sync! rescue StandardError => error diff --git a/app/controllers/simplefin_items_controller.rb b/app/controllers/simplefin_items_controller.rb index 2aeb01f5d..32ba1deb4 100644 --- a/app/controllers/simplefin_items_controller.rb +++ b/app/controllers/simplefin_items_controller.rb @@ -1,6 +1,7 @@ class SimplefinItemsController < ApplicationController include SimplefinItems::MapsHelper before_action :set_simplefin_item, only: [ :show, :edit, :update, :destroy, :sync, :balances, :setup_accounts, :complete_account_setup ] + before_action :require_admin!, only: [ :new, :create, :select_existing_account, :link_existing_account, :edit, :update, :destroy, :sync, :balances, :setup_accounts, :complete_account_setup ] def index @simplefin_items = Current.family.simplefin_items.active.ordered diff --git a/app/controllers/snaptrade_items_controller.rb b/app/controllers/snaptrade_items_controller.rb index e5d771b75..f02d5a155 100644 --- a/app/controllers/snaptrade_items_controller.rb +++ b/app/controllers/snaptrade_items_controller.rb @@ -1,5 +1,6 @@ class SnaptradeItemsController < ApplicationController before_action :set_snaptrade_item, only: [ :show, :edit, :update, :destroy, :sync, :connect, :setup_accounts, :complete_account_setup, :connections, :delete_connection, :delete_orphaned_user ] + before_action :require_admin!, only: [ :new, :create, :preload_accounts, :select_accounts, :link_accounts, :select_existing_account, :link_existing_account, :edit, :update, :destroy, :sync, :connect, :callback, :setup_accounts, :complete_account_setup, :connections, :delete_connection, :delete_orphaned_user ] def index @snaptrade_items = Current.family.snaptrade_items.ordered @@ -149,8 +150,12 @@ class SnaptradeItemsController < ApplicationController no_accounts = @unlinked_accounts.blank? && @linked_accounts.blank? - # If no accounts and not syncing, trigger a sync - if no_accounts && !@snaptrade_item.syncing? + # We trigger an initial or recovery sync if there are no accounts, we aren't currently syncing, + # and the last attempt didn't successfully complete. (If it completed and found 0 accounts, we stop here to avoid an infinite loop.) + latest_sync = @snaptrade_item.syncs.ordered.first + should_sync = latest_sync.nil? || !latest_sync.completed? + + if no_accounts && !@snaptrade_item.syncing? && should_sync @snaptrade_item.sync_later end @@ -216,8 +221,9 @@ class SnaptradeItemsController < ApplicationController if errors.any? # Partial success - some linked, some failed - redirect_to accounts_path, notice: t(".partial_success", linked: linked_count, failed: errors.size, - default: "Linked #{linked_count} account(s). #{errors.size} failed to link.") + redirect_to accounts_path, + notice: t(".partial_success", count: linked_count, failed_count: errors.size, + default: "Linked #{linked_count} account(s). #{errors.size} failed to link.") else redirect_to accounts_path, notice: t(".success", count: linked_count, default: "Successfully linked #{linked_count} account(s).") end diff --git a/app/controllers/splits_controller.rb b/app/controllers/splits_controller.rb new file mode 100644 index 000000000..a62540e9d --- /dev/null +++ b/app/controllers/splits_controller.rb @@ -0,0 +1,100 @@ +class SplitsController < ApplicationController + before_action :set_entry + before_action :require_split_write_permission!, only: %i[create update destroy] + + def new + @categories = Current.family.categories.alphabetically + end + + def create + unless @entry.transaction.splittable? + redirect_back_or_to transactions_path, alert: t("splits.create.not_splittable") + return + end + + raw_splits = split_params[:splits] + raw_splits = raw_splits.values if raw_splits.respond_to?(:values) + + splits = raw_splits.map do |s| + { name: s[:name], amount: s[:amount].to_d * -1, category_id: s[:category_id].presence } + end + + @entry.split!(splits) + @entry.sync_account_later + + redirect_back_or_to transactions_path, notice: t("splits.create.success") + rescue ActiveRecord::RecordInvalid => e + redirect_back_or_to transactions_path, alert: e.message + end + + def edit + resolve_to_parent! + + unless @entry.split_parent? + redirect_to transactions_path, alert: t("splits.edit.not_split") + return + end + + @categories = Current.family.categories.alphabetically + @children = @entry.child_entries.includes(:entryable) + end + + def update + resolve_to_parent! + + unless @entry.split_parent? + redirect_to transactions_path, alert: t("splits.edit.not_split") + return + end + + raw_splits = split_params[:splits] + raw_splits = raw_splits.values if raw_splits.respond_to?(:values) + + splits = raw_splits.map do |s| + { name: s[:name], amount: s[:amount].to_d * -1, category_id: s[:category_id].presence } + end + + Entry.transaction do + @entry.unsplit! + @entry.split!(splits) + end + + @entry.sync_account_later + + redirect_to transactions_path, notice: t("splits.update.success") + rescue ActiveRecord::RecordInvalid => e + redirect_to transactions_path, alert: e.message + end + + def destroy + resolve_to_parent! + + unless @entry.split_parent? + redirect_to transactions_path, alert: t("splits.edit.not_split") + return + end + + @entry.unsplit! + @entry.sync_account_later + + redirect_to transactions_path, notice: t("splits.destroy.success") + end + + private + + def set_entry + @entry = Current.accessible_entries.find(params[:transaction_id]) + end + + def require_split_write_permission! + require_account_permission!(@entry.account, redirect_path: transactions_path) + end + + def resolve_to_parent! + @entry = @entry.parent_entry if @entry.split_child? + end + + def split_params + params.require(:split).permit(splits: [ :name, :amount, :category_id ]) + end +end diff --git a/app/controllers/trades_controller.rb b/app/controllers/trades_controller.rb index e3061a719..e3a90121e 100644 --- a/app/controllers/trades_controller.rb +++ b/app/controllers/trades_controller.rb @@ -5,7 +5,7 @@ class TradesController < ApplicationController # Defaults to a buy trade def new - @account = Current.family.accounts.find_by(id: params[:account_id]) + @account = accessible_accounts.find_by(id: params[:account_id]) @model = Current.family.entries.new( account: @account, currency: @account ? @account.currency : Current.family.currency, @@ -15,7 +15,10 @@ class TradesController < ApplicationController # Can create a trade, transaction (e.g. "fees"), or transfer (e.g. "withdrawal") def create - @account = Current.family.accounts.find(params[:account_id]) + @account = accessible_accounts.find(params[:account_id]) + + return unless require_account_permission!(@account) + @model = Trade::CreateForm.new(create_params.merge(account: @account)).create if @model.persisted? @@ -37,6 +40,8 @@ class TradesController < ApplicationController end def update + return unless require_account_permission!(@entry.account) + if @entry.update(update_entry_params) @entry.lock_saved_attributes! @entry.mark_user_modified! @@ -69,6 +74,8 @@ class TradesController < ApplicationController end def unlock + return unless require_account_permission!(@entry.account) + @entry.unlock_for_sync! flash[:notice] = t("entries.unlock.success") @@ -77,38 +84,50 @@ class TradesController < ApplicationController private def set_entry_for_unlock - trade = Current.family.trades.find(params[:id]) + trade = Current.family.trades + .joins(entry: :account) + .merge(Account.accessible_by(Current.user)) + .find(params[:id]) @entry = trade.entry end def entry_params params.require(:entry).permit( :name, :date, :amount, :currency, :excluded, :notes, :nature, - entryable_attributes: [ :id, :qty, :price, :investment_activity_label ] + entryable_attributes: [ :id, :qty, :price, :fee, :investment_activity_label ] ) end def create_params params.require(:model).permit( - :date, :amount, :currency, :qty, :price, :ticker, :manual_ticker, :type, :transfer_account_id + :date, :amount, :currency, :qty, :price, :fee, :ticker, :manual_ticker, :type, :transfer_account_id ) end def update_entry_params - return entry_params unless entry_params[:entryable_attributes].present? - update_params = entry_params + + # Income trades (Dividend/Interest) store amounts as negative (inflow convention). + # The form displays the absolute value, so we re-negate before saving. + if %w[Dividend Interest].include?(@entry.trade&.investment_activity_label) && update_params[:amount].present? + update_params = update_params.merge(amount: -update_params[:amount].to_d.abs) + end + + return update_params unless update_params[:entryable_attributes].present? + update_params = update_params.merge(entryable_type: "Trade") qty = update_params[:entryable_attributes][:qty] price = update_params[:entryable_attributes][:price] + fee = update_params[:entryable_attributes][:fee] nature = update_params[:nature] if qty.present? && price.present? is_sell = nature == "inflow" qty = is_sell ? -qty.to_d.abs : qty.to_d.abs + fee_val = fee.present? ? fee.to_d : (@entry.trade&.fee || 0) update_params[:entryable_attributes][:qty] = qty - update_params[:amount] = qty * price.to_d + update_params[:amount] = qty * price.to_d + fee_val # Sync investment_activity_label with Buy/Sell type if not explicitly set to something else # Check both the submitted param and the existing record's label diff --git a/app/controllers/transaction_attachments_controller.rb b/app/controllers/transaction_attachments_controller.rb new file mode 100644 index 000000000..2a9ff5567 --- /dev/null +++ b/app/controllers/transaction_attachments_controller.rb @@ -0,0 +1,118 @@ +class TransactionAttachmentsController < ApplicationController + before_action :set_transaction + before_action :set_attachment, only: [ :show, :destroy ] + before_action :set_permissions, only: [ :create, :destroy ] + + def show + disposition = params[:disposition] == "attachment" ? "attachment" : "inline" + redirect_to rails_blob_url(@attachment, disposition: disposition) + end + + def create + unless @can_upload + redirect_back_or_to transaction_path(@transaction), alert: t("accounts.not_authorized") + return + end + + attachments = attachment_params + + if attachments.present? + @transaction.with_lock do + # Check attachment count limit before attaching + current_count = @transaction.attachments.count + new_count = attachments.is_a?(Array) ? attachments.length : 1 + + if current_count + new_count > Transaction::MAX_ATTACHMENTS_PER_TRANSACTION + respond_to do |format| + format.html { redirect_back_or_to transaction_path(@transaction), alert: t("transactions.attachments.cannot_exceed", count: Transaction::MAX_ATTACHMENTS_PER_TRANSACTION) } + format.turbo_stream { flash.now[:alert] = t("transactions.attachments.cannot_exceed", count: Transaction::MAX_ATTACHMENTS_PER_TRANSACTION) } + end + return + end + + existing_ids = @transaction.attachments.pluck(:id) + attachment_proxy = @transaction.attachments.attach(attachments) + + if @transaction.valid? + count = new_count + message = count == 1 ? t("transactions.attachments.uploaded_one") : t("transactions.attachments.uploaded_many", count: count) + respond_to do |format| + format.html { redirect_back_or_to transaction_path(@transaction), notice: message } + format.turbo_stream { flash.now[:notice] = message } + end + else + # Remove invalid attachments + newly_added = Array(attachment_proxy).reject { |a| existing_ids.include?(a.id) } + newly_added.each(&:purge) + error_messages = @transaction.errors.full_messages_for(:attachments).join(", ") + respond_to do |format| + format.html { redirect_back_or_to transaction_path(@transaction), alert: t("transactions.attachments.failed_upload", error: error_messages) } + format.turbo_stream { flash.now[:alert] = t("transactions.attachments.failed_upload", error: error_messages) } + end + end + end + else + respond_to do |format| + format.html { redirect_back_or_to transaction_path(@transaction), alert: t("transactions.attachments.no_files_selected") } + format.turbo_stream { flash.now[:alert] = t("transactions.attachments.no_files_selected") } + end + end + rescue => e + logger.error "#{e.class}: #{e.message}\n#{e.backtrace.join("\n")}" + respond_to do |format| + format.html { redirect_back_or_to transaction_path(@transaction), alert: t("transactions.attachments.upload_failed") } + format.turbo_stream { flash.now[:alert] = t("transactions.attachments.upload_failed") } + end + end + + def destroy + unless @can_delete + redirect_back_or_to transaction_path(@transaction), alert: t("accounts.not_authorized") + return + end + + @attachment.purge + message = t("transactions.attachments.attachment_deleted") + respond_to do |format| + format.html { redirect_back_or_to transaction_path(@transaction), notice: message } + format.turbo_stream { flash.now[:notice] = message } + end + rescue => e + logger.error "#{e.class}: #{e.message}\n#{e.backtrace.join("\n")}" + respond_to do |format| + format.html { redirect_back_or_to transaction_path(@transaction), alert: t("transactions.attachments.delete_failed") } + format.turbo_stream { flash.now[:alert] = t("transactions.attachments.delete_failed") } + end + end + + private + + def set_transaction + @transaction = Current.family.transactions + .joins(entry: :account) + .merge(Account.accessible_by(Current.user)) + .find(params[:transaction_id]) + end + + def set_attachment + @attachment = @transaction.attachments.find(params[:id]) + end + + def set_permissions + permission = @transaction.entry.account.permission_for(Current.user) + @can_upload = permission.in?([ :owner, :full_control, :read_write ]) + @can_delete = permission.in?([ :owner, :full_control ]) + end + + def attachment_params + if params.has_key?(:attachments) + Array(params.fetch(:attachments, [])).reject(&:blank?).map do |param| + param.respond_to?(:permit) ? param.permit(:file, :filename, :content_type, :description, :metadata) : param + end + elsif params.has_key?(:attachment) + param = params[:attachment] + return nil if param.blank? + param.respond_to?(:permit) ? param.permit(:file, :filename, :content_type, :description, :metadata) : param + end + end +end diff --git a/app/controllers/transaction_categories_controller.rb b/app/controllers/transaction_categories_controller.rb index d8fc64e96..6a8ff353a 100644 --- a/app/controllers/transaction_categories_controller.rb +++ b/app/controllers/transaction_categories_controller.rb @@ -2,7 +2,9 @@ class TransactionCategoriesController < ApplicationController include ActionView::RecordIdentifier def update - @entry = Current.family.entries.transactions.find(params[:transaction_id]) + @entry = Current.accessible_entries.transactions.find(params[:transaction_id]) + return unless require_account_permission!(@entry.account, :annotate, redirect_path: transaction_path(@entry)) + @entry.update!(entry_params) transaction = @entry.transaction diff --git a/app/controllers/transactions/bulk_deletions_controller.rb b/app/controllers/transactions/bulk_deletions_controller.rb index fefaf389f..2cf1fc2df 100644 --- a/app/controllers/transactions/bulk_deletions_controller.rb +++ b/app/controllers/transactions/bulk_deletions_controller.rb @@ -1,6 +1,12 @@ class Transactions::BulkDeletionsController < ApplicationController def create - destroyed = Current.family.entries.destroy_by(id: bulk_delete_params[:entry_ids]) + # Exclude split children from bulk delete - they must be deleted via unsplit on parent + # Only allow deletion from accounts where user has owner or full_control permission + writable_account_ids = writable_accounts.pluck(:id) + entries_scope = Current.family.entries + .where(account_id: writable_account_ids) + .where(parent_entry_id: nil) + destroyed = entries_scope.destroy_by(id: bulk_delete_params[:entry_ids]) destroyed.map(&:account).uniq.each(&:sync_later) redirect_back_or_to transactions_url, notice: "#{destroyed.count} transaction#{destroyed.count == 1 ? "" : "s"} deleted" end @@ -9,4 +15,8 @@ class Transactions::BulkDeletionsController < ApplicationController def bulk_delete_params params.require(:bulk_delete).permit(entry_ids: []) end + + def writable_accounts + Current.family.accounts.writable_by(Current.user) + end end diff --git a/app/controllers/transactions/bulk_updates_controller.rb b/app/controllers/transactions/bulk_updates_controller.rb index 8a115e58d..82d4c6ddf 100644 --- a/app/controllers/transactions/bulk_updates_controller.rb +++ b/app/controllers/transactions/bulk_updates_controller.rb @@ -3,8 +3,10 @@ class Transactions::BulkUpdatesController < ApplicationController end def create + # Skip split parents from bulk update - update children instead updated = Current.family .entries + .excluding_split_parents .where(id: bulk_update_params[:entry_ids]) .bulk_update!(bulk_update_params, update_tags: tags_provided?) diff --git a/app/controllers/transactions/categorizes_controller.rb b/app/controllers/transactions/categorizes_controller.rb new file mode 100644 index 000000000..b65ebea64 --- /dev/null +++ b/app/controllers/transactions/categorizes_controller.rb @@ -0,0 +1,134 @@ +class Transactions::CategorizesController < ApplicationController + def show + @breadcrumbs = [ + [ t("breadcrumbs.home"), root_path ], + [ t("breadcrumbs.transactions"), transactions_path ], + [ t("breadcrumbs.categorize"), nil ] + ] + @position = [ params[:position].to_i, 0 ].max + groups = Transaction::Grouper.strategy.call( + Current.accessible_entries, + limit: 1, + offset: @position + ) + + if groups.empty? + redirect_to transactions_path, notice: t(".all_done") and return + end + + @group = groups.first + @categories = Current.family.categories.alphabetically + @total_uncategorized = uncategorized_count + end + + def create + @position = params[:position].to_i + entry_ids = Array.wrap(params[:entry_ids]).reject(&:blank?) + all_entry_ids = Array.wrap(params[:all_entry_ids]).reject(&:blank?) + remaining_ids = all_entry_ids - entry_ids + + category = Current.family.categories.find(params[:category_id]) + entries = Current.accessible_entries.excluding_split_parents.where(id: entry_ids) + count = entries.bulk_update!({ category_id: category.id }) + + if params[:create_rule] == "1" + rule = Rule.create_from_grouping( + Current.family, + params[:grouping_key], + category, + transaction_type: params[:transaction_type] + ) + flash[:alert] = t(".rule_creation_failed") if rule.nil? + end + + respond_to do |format| + format.turbo_stream do + remaining_entries = uncategorized_entries_for(remaining_ids) + remaining_ids = remaining_entries.map { |e| e.id.to_s } + + if remaining_ids.empty? + render turbo_stream: turbo_stream.action(:redirect, transactions_categorize_path(position: @position)) + else + @categories = Current.family.categories.alphabetically + streams = entry_ids.map { |id| turbo_stream.remove("categorize_entry_#{id}") } + remaining_entries.each do |entry| + streams << turbo_stream.replace( + "categorize_entry_#{entry.id}", + partial: "transactions/categorizes/entry_row", + locals: { entry: entry, categories: @categories } + ) + end + streams << turbo_stream.replace("categorize_remaining", + partial: "transactions/categorizes/remaining_count", + locals: { total_uncategorized: uncategorized_count }) + streams << turbo_stream.replace("categorize_group_summary", + partial: "transactions/categorizes/group_summary", + locals: { entries: remaining_entries }) + streams.concat(flash_notification_stream_items) + render turbo_stream: streams + end + end + format.html { redirect_to transactions_categorize_path(position: @position), notice: t(".categorized", count: count) } + end + end + + def preview_rule + filter = params[:filter].to_s.strip + transaction_type = params[:transaction_type].presence + entries = filter.present? ? Entry.uncategorized_matching(Current.accessible_entries, filter, transaction_type) : [] + @categories = Current.family.categories.alphabetically + + render turbo_stream: [ + turbo_stream.replace("categorize_group_title", + partial: "transactions/categorizes/group_title", + locals: { display_name: filter.presence || "…", color: "#737373", transaction_type: transaction_type }), + turbo_stream.replace("categorize_group_summary", + partial: "transactions/categorizes/group_summary", + locals: { entries: entries }), + turbo_stream.replace("categorize_transaction_list", + partial: "transactions/categorizes/transaction_list", + locals: { entries: entries, categories: @categories }) + ] + end + + def assign_entry + entry = Current.accessible_entries.excluding_split_parents.find(params[:entry_id]) + category = Current.family.categories.find(params[:category_id]) + position = params[:position].to_i + all_entry_ids = Array.wrap(params[:all_entry_ids]).reject(&:blank?) + remaining_ids = all_entry_ids - [ entry.id.to_s ] + + Current.accessible_entries.where(id: entry.id).bulk_update!({ category_id: category.id }) + + remaining_entries = uncategorized_entries_for(remaining_ids) + remaining_ids = remaining_entries.map { |e| e.id.to_s } + + streams = [ turbo_stream.remove("categorize_entry_#{entry.id}") ] + if remaining_ids.empty? + streams << turbo_stream.action(:redirect, transactions_categorize_path(position: position)) + else + streams << turbo_stream.replace("categorize_remaining", + partial: "transactions/categorizes/remaining_count", + locals: { total_uncategorized: uncategorized_count }) + streams << turbo_stream.replace("categorize_group_summary", + partial: "transactions/categorizes/group_summary", + locals: { entries: remaining_entries }) + end + render turbo_stream: streams + end + + private + + def uncategorized_count + Current.accessible_entries.uncategorized_transactions.count + end + + def uncategorized_entries_for(ids) + return [] if ids.blank? + Current.accessible_entries + .excluding_split_parents + .where(id: ids) + .uncategorized_transactions + .to_a + end +end diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index 0b775826d..6548d26ed 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -5,14 +5,18 @@ class TransactionsController < ApplicationController before_action :store_params!, only: :index def new + prefill_params_from_duplicate! super + apply_duplicate_attributes! @income_categories = Current.family.categories.incomes.alphabetically @expense_categories = Current.family.categories.expenses.alphabetically + @categories = Current.family.categories.alphabetically end def index @q = search_params - @search = Transaction::Search.new(Current.family, filters: @q) + accessible_account_ids = Current.user.accessible_accounts.pluck(:id) + @search = Transaction::Search.new(Current.family, filters: @q, accessible_account_ids: accessible_account_ids) base_scope = @search.transactions_scope .reverse_chronological @@ -24,8 +28,35 @@ class TransactionsController < ApplicationController @pagy, @transactions = pagy(base_scope, limit: safe_per_page) + # Preload split parent data + entry_ids = @transactions.map { |t| t.entry.id } + + # Load split parent entries for grouped display (only when grouping is enabled) + @split_parents = if Current.user.show_split_grouped? + split_parent_ids = @transactions.filter_map { |t| t.entry.parent_entry_id }.uniq + if split_parent_ids.any? + Entry.where(id: split_parent_ids) + .includes(:account, entryable: [ :category, :merchant ]) + .index_by(&:id) + else + {} + end + else + {} + end + + # Preload which entries on this page are split parents (have children) to avoid N+1 + @split_parent_entry_ids = if entry_ids.any? + Entry.where(parent_entry_id: entry_ids).distinct.pluck(:parent_entry_id).to_set + else + Set.new + end + + @uncategorized_count = Current.accessible_entries.uncategorized_transactions.count + # Load projected recurring transactions for next 10 days @projected_recurring = Current.family.recurring_transactions + .accessible_by(Current.user) .active .where("next_expected_date <= ? AND next_expected_date >= ?", 10.days.from_now.to_date, @@ -63,7 +94,10 @@ class TransactionsController < ApplicationController end def create - account = Current.family.accounts.find(params.dig(:entry, :account_id)) + account = Current.user.accessible_accounts.find(params.dig(:entry, :account_id)) + + return unless require_account_permission!(account) + @entry = account.entries.new(entry_params) if @entry.save @@ -84,7 +118,7 @@ class TransactionsController < ApplicationController end def update - if @entry.update(entry_params) + if @entry.update(permitted_entry_params) transaction = @entry.transaction if needs_rule_notification?(transaction) @@ -128,7 +162,9 @@ class TransactionsController < ApplicationController end def merge_duplicate - transaction = Current.family.transactions.includes(entry: :account).find(params[:id]) + transaction = accessible_transactions.includes(entry: :account).find(params[:id]) + + return unless require_account_permission!(transaction.entry.account) if transaction.merge_with_duplicate! flash[:notice] = t("transactions.merge_duplicate.success") @@ -144,7 +180,9 @@ class TransactionsController < ApplicationController end def dismiss_duplicate - transaction = Current.family.transactions.includes(entry: :account).find(params[:id]) + transaction = accessible_transactions.includes(entry: :account).find(params[:id]) + + return unless require_account_permission!(transaction.entry.account) if transaction.dismiss_duplicate_suggestion! flash[:notice] = t("transactions.dismiss_duplicate.success") @@ -160,9 +198,11 @@ class TransactionsController < ApplicationController end def convert_to_trade - @transaction = Current.family.transactions.includes(entry: :account).find(params[:id]) + @transaction = accessible_transactions.includes(entry: :account).find(params[:id]) @entry = @transaction.entry + return unless require_account_permission!(@entry.account) + unless @entry.account.investment? flash[:alert] = t("transactions.convert_to_trade.errors.not_investment_account") redirect_back_or_to transactions_path @@ -173,9 +213,11 @@ class TransactionsController < ApplicationController end def create_trade_from_transaction - @transaction = Current.family.transactions.includes(entry: :account).find(params[:id]) + @transaction = accessible_transactions.includes(entry: :account).find(params[:id]) @entry = @transaction.entry + return unless require_account_permission!(@entry.account) + # Pre-transaction validations unless @entry.account.investment? flash[:alert] = t("transactions.convert_to_trade.errors.not_investment_account") @@ -250,6 +292,8 @@ class TransactionsController < ApplicationController end def unlock + return unless require_account_permission!(@entry.account) + @entry.unlock_for_sync! flash[:notice] = t("entries.unlock.success") @@ -257,10 +301,13 @@ class TransactionsController < ApplicationController end def mark_as_recurring - transaction = Current.family.transactions.includes(entry: :account).find(params[:id]) + transaction = accessible_transactions.includes(entry: :account).find(params[:id]) + + return unless require_account_permission!(transaction.entry.account) # Check if a recurring transaction already exists for this pattern existing = Current.family.recurring_transactions.find_by( + account_id: transaction.entry.account_id, merchant_id: transaction.merchant_id, name: transaction.merchant_id.present? ? nil : transaction.entry.name, currency: transaction.entry.currency, @@ -307,8 +354,43 @@ class TransactionsController < ApplicationController end private + def accessible_transactions + Current.family.transactions + .joins(entry: :account) + .merge(Account.accessible_by(Current.user)) + end + + def duplicate_source + return @duplicate_source if defined?(@duplicate_source) + @duplicate_source = if params[:duplicate_entry_id].present? + source = Current.family.entries.joins(:account).merge(Account.accessible_by(Current.user)).find_by(id: params[:duplicate_entry_id]) + source if source&.transaction? + end + end + + def prefill_params_from_duplicate! + return unless duplicate_source + params[:nature] ||= duplicate_source.amount.negative? ? "inflow" : "outflow" + params[:account_id] ||= duplicate_source.account_id.to_s + end + + def apply_duplicate_attributes! + return unless duplicate_source + @entry.assign_attributes( + name: duplicate_source.name, + amount: duplicate_source.amount.abs, + currency: duplicate_source.currency, + notes: duplicate_source.notes + ) + @entry.entryable.assign_attributes( + category_id: duplicate_source.entryable.category_id, + merchant_id: duplicate_source.entryable.merchant_id + ) + @entry.entryable.tag_ids = duplicate_source.entryable.tag_ids + end + def set_entry_for_unlock - transaction = Current.family.transactions.find(params[:id]) + transaction = accessible_transactions.find(params[:id]) @entry = transaction.entry end @@ -332,6 +414,8 @@ class TransactionsController < ApplicationController nature = entry_params.delete(:nature) + entry_params.delete(:amount) if entry_params[:amount].blank? + if nature.present? && entry_params[:amount].present? signed_amount = nature == "inflow" ? -entry_params[:amount].to_d : entry_params[:amount].to_d entry_params = entry_params.merge(amount: signed_amount) @@ -340,6 +424,25 @@ class TransactionsController < ApplicationController entry_params end + # Filters entry_params based on the user's permission on the account. + # read_write users can only annotate (category, tags, notes, merchant). + # read_only users cannot update anything. + def permitted_entry_params + case entry_permission + when :owner, :full_control + entry_params + when :read_write + # Annotate only: category, tags, merchant, notes + ep = entry_params.slice(:notes) + if entry_params[:entryable_attributes].present? + ep[:entryable_attributes] = entry_params[:entryable_attributes].slice(:id, :category_id, :merchant_id, :tag_ids) + end + ep + else + {} # read_only — no edits allowed + end + end + def search_params cleaned_params = params.fetch(:q, {}) .permit( diff --git a/app/controllers/transfer_matches_controller.rb b/app/controllers/transfer_matches_controller.rb index ea9425039..341688d5d 100644 --- a/app/controllers/transfer_matches_controller.rb +++ b/app/controllers/transfer_matches_controller.rb @@ -2,11 +2,16 @@ class TransferMatchesController < ApplicationController before_action :set_entry def new - @accounts = Current.family.accounts.visible.alphabetically.where.not(id: @entry.account_id) + @accounts = Current.family.accounts.writable_by(Current.user).visible.alphabetically.where.not(id: @entry.account_id) @transfer_match_candidates = @entry.transaction.transfer_match_candidates end def create + return unless require_account_permission!(@entry.account, redirect_path: transactions_path) + + target_account = resolve_target_account + return unless require_account_permission!(target_account, redirect_path: transactions_path) + @transfer = build_transfer Transfer.transaction do @transfer.save! @@ -32,16 +37,24 @@ class TransferMatchesController < ApplicationController private def set_entry - @entry = Current.family.entries.find(params[:transaction_id]) + @entry = Current.accessible_entries.find(params[:transaction_id]) end def transfer_match_params params.require(:transfer_match).permit(:method, :matched_entry_id, :target_account_id) end + def resolve_target_account + if transfer_match_params[:method] == "new" + accessible_accounts.find(transfer_match_params[:target_account_id]) + else + Current.accessible_entries.find(transfer_match_params[:matched_entry_id]).account + end + end + def build_transfer if transfer_match_params[:method] == "new" - target_account = Current.family.accounts.find(transfer_match_params[:target_account_id]) + target_account = accessible_accounts.find(transfer_match_params[:target_account_id]) missing_transaction = Transaction.new( entry: target_account.entries.build( @@ -60,7 +73,7 @@ class TransferMatchesController < ApplicationController transfer.status = "confirmed" transfer else - target_transaction = Current.family.entries.find(transfer_match_params[:matched_entry_id]) + target_transaction = Current.accessible_entries.find(transfer_match_params[:matched_entry_id]) transfer = Transfer.find_or_initialize_by( inflow_transaction: @entry.amount.negative? ? @entry.transaction : target_transaction.transaction, diff --git a/app/controllers/transfers_controller.rb b/app/controllers/transfers_controller.rb index 248f8d4ad..0a000fad6 100644 --- a/app/controllers/transfers_controller.rb +++ b/app/controllers/transfers_controller.rb @@ -6,18 +6,32 @@ class TransfersController < ApplicationController def new @transfer = Transfer.new @from_account_id = params[:from_account_id] + + @accounts = accessible_accounts + .alphabetically + .includes( + :account_providers, + logo_attachment: :blob + ) end def show - @categories = Current.family.categories.expenses + @categories = Current.family.categories.alphabetically end def create + # Validate user has write access to both accounts + source_account = accessible_accounts.find(transfer_params[:from_account_id]) + destination_account = accessible_accounts.find(transfer_params[:to_account_id]) + + return unless require_account_permission!(source_account, redirect_path: transactions_path) + return unless require_account_permission!(destination_account, redirect_path: transactions_path) + @transfer = Transfer::Creator.new( family: Current.family, - source_account_id: transfer_params[:from_account_id], - destination_account_id: transfer_params[:to_account_id], - date: transfer_params[:date], + source_account_id: source_account.id, + destination_account_id: destination_account.id, + date: Date.parse(transfer_params[:date]), amount: transfer_params[:amount].to_d ).create @@ -28,11 +42,15 @@ class TransfersController < ApplicationController format.turbo_stream { stream_redirect_back_or_to transactions_path, notice: success_message } end else + @from_account_id = transfer_params[:from_account_id] render :new, status: :unprocessable_entity end end def update + outflow_account = @transfer.outflow_transaction.entry.account + return unless require_account_permission!(outflow_account, redirect_path: transactions_url) + Transfer.transaction do update_transfer_status update_transfer_details unless transfer_update_params[:status] == "rejected" @@ -45,16 +63,24 @@ class TransfersController < ApplicationController end def destroy + outflow_account = @transfer.outflow_transaction.entry.account + return unless require_account_permission!(outflow_account, redirect_path: transactions_url) + @transfer.destroy! redirect_back_or_to transactions_url, notice: t(".success") end private def set_transfer - # Finds the transfer and ensures the family owns it + # Finds the transfer and ensures the user has access to it + accessible_transaction_ids = Current.family.transactions + .joins(entry: :account) + .merge(Account.accessible_by(Current.user)) + .select(:id) + @transfer = Transfer .where(id: params[:id]) - .where(inflow_transaction_id: Current.family.transactions.select(:id)) + .where(inflow_transaction_id: accessible_transaction_ids) .first! end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 252cd114d..ebf250d03 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -12,7 +12,7 @@ class UsersController < ApplicationController def update @user = Current.user - return if moniker_change_requested? && !ensure_admin + return if admin_family_change_requested? && !ensure_admin if email_changed? if @user.initiate_email_change(user_params[:email]) @@ -83,6 +83,8 @@ class UsersController < ApplicationController redirect_to goals_onboarding_path when "trial" redirect_to trial_onboarding_path + when "appearance" + redirect_to settings_appearance_path, notice: notice when "ai_prompts" redirect_to settings_ai_prompts_path, notice: notice else @@ -104,10 +106,13 @@ class UsersController < ApplicationController end def user_params + family_attrs = [ :name, :currency, :country, :date_format, :timezone, :locale, :month_start_day, :id ] + family_attrs.push(:moniker, :default_account_sharing) if Current.user.admin? + params.require(:user).permit( :first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, :show_sidebar, :default_period, :default_account_order, :show_ai_sidebar, :ai_enabled, :theme, :set_onboarding_preferences_at, :set_onboarding_goals_at, :locale, - family_attributes: [ :name, :currency, :country, :date_format, :timezone, :locale, :month_start_day, :moniker, :id ], + family_attributes: family_attrs, goals: [] ) end @@ -116,11 +121,14 @@ class UsersController < ApplicationController @user = Current.user end - def moniker_change_requested? - requested_moniker = params.dig(:user, :family_attributes, :moniker) - return false if requested_moniker.blank? + def admin_family_change_requested? + family_attrs = params.dig(:user, :family_attributes) + return false if family_attrs.blank? - requested_moniker != Current.family.moniker + moniker_changed = family_attrs[:moniker].present? && family_attrs[:moniker] != Current.family.moniker + sharing_changed = family_attrs[:default_account_sharing].present? && family_attrs[:default_account_sharing] != Current.family.default_account_sharing + + moniker_changed || sharing_changed end def ensure_admin diff --git a/app/controllers/valuations_controller.rb b/app/controllers/valuations_controller.rb index 5234c0bea..32ec2df74 100644 --- a/app/controllers/valuations_controller.rb +++ b/app/controllers/valuations_controller.rb @@ -2,7 +2,9 @@ class ValuationsController < ApplicationController include EntryableResource, StreamExtensions def confirm_create - @account = Current.family.accounts.find(params.dig(:entry, :account_id)) + @account = accessible_accounts.find(params.dig(:entry, :account_id)) + return unless require_account_permission!(@account) + @entry = @account.entries.build(entry_params.merge(currency: @account.currency)) @reconciliation_dry_run = @entry.account.create_reconciliation( @@ -15,7 +17,9 @@ class ValuationsController < ApplicationController end def confirm_update - @entry = Current.family.entries.find(params[:id]) + @entry = Current.accessible_entries.find(params[:id]) + return unless require_account_permission!(@entry.account) + @account = @entry.account @entry.assign_attributes(entry_params.merge(currency: @account.currency)) @@ -30,7 +34,8 @@ class ValuationsController < ApplicationController end def create - account = Current.family.accounts.find(params.dig(:entry, :account_id)) + account = accessible_accounts.find(params.dig(:entry, :account_id)) + return unless require_account_permission!(account) result = account.create_reconciliation( balance: entry_params[:amount], @@ -49,6 +54,8 @@ class ValuationsController < ApplicationController end def update + return unless require_account_permission!(@entry.account) + # Notes updating is independent of reconciliation, just a simple CRUD operation @entry.update!(notes: entry_params[:notes]) if entry_params[:notes].present? diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 15777b223..7f0aad0bd 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -108,6 +108,11 @@ module ApplicationHelper cookies[:admin] == "true" end + def assistant_icon + type = ENV["ASSISTANT_TYPE"].presence || Current.family&.assistant_type.presence || "builtin" + type == "external" ? "claw" : "ai" + end + def default_ai_model # Always return a valid model, never nil or empty # Delegates to Chat.default_model for consistency @@ -139,6 +144,15 @@ module ApplicationHelper markdown.render(text).html_safe end + # Generate the callback URL for Enable Banking OAuth (used in views and controller). + # In production, uses the standard Rails route. + # In development, uses DEV_WEBHOOKS_URL if set (e.g., ngrok URL). + def enable_banking_callback_url + return callback_enable_banking_items_url if Rails.env.production? + + ENV.fetch("DEV_WEBHOOKS_URL", root_url).chomp("/") + "/enable_banking_items/callback" + end + # Formats quantity with adaptive precision based on the value size. # Shows more decimal places for small quantities (common with crypto). # diff --git a/app/helpers/entries_helper.rb b/app/helpers/entries_helper.rb index 1c3340ae0..87a3fc571 100644 --- a/app/helpers/entries_helper.rb +++ b/app/helpers/entries_helper.rb @@ -1,4 +1,28 @@ module EntriesHelper + SplitGroup = Data.define(:parent, :children) + + def group_split_entries(entries, split_parents) + return entries if split_parents.blank? + + result = [] + seen_parent_ids = Set.new + + entries.each do |entry| + if entry.split_child? && split_parents[entry.parent_entry_id] + parent_id = entry.parent_entry_id + next if seen_parent_ids.include?(parent_id) + + seen_parent_ids.add(parent_id) + children = entries.select { |e| e.parent_entry_id == parent_id } + result << SplitGroup.new(parent: split_parents[parent_id], children: children) + else + result << entry + end + end + + result + end + def entries_by_date(entries, totals: false) transfer_groups = entries.group_by do |entry| # Only check for transfer if it's a transaction diff --git a/app/helpers/imports_helper.rb b/app/helpers/imports_helper.rb index e7ff95e03..6cf680452 100644 --- a/app/helpers/imports_helper.rb +++ b/app/helpers/imports_helper.rb @@ -25,7 +25,6 @@ module ImportsHelper entity_type: "Type", category_parent: "Parent category", category_color: "Color", - category_classification: "Classification", category_icon: "Lucide icon" }[key] end @@ -36,7 +35,12 @@ module ImportsHelper accounts: DryRunResource.new(label: "Accounts", icon: "layers", text_class: "text-orange-500", bg_class: "bg-orange-500/5"), categories: DryRunResource.new(label: "Categories", icon: "shapes", text_class: "text-blue-500", bg_class: "bg-blue-500/5"), tags: DryRunResource.new(label: "Tags", icon: "tags", text_class: "text-violet-500", bg_class: "bg-violet-500/5"), - rules: DryRunResource.new(label: "Rules", icon: "workflow", text_class: "text-green-500", bg_class: "bg-green-500/5") + rules: DryRunResource.new(label: "Rules", icon: "workflow", text_class: "text-green-500", bg_class: "bg-green-500/5"), + merchants: DryRunResource.new(label: "Merchants", icon: "store", text_class: "text-amber-500", bg_class: "bg-amber-500/5"), + trades: DryRunResource.new(label: "Trades", icon: "arrow-left-right", text_class: "text-emerald-500", bg_class: "bg-emerald-500/5"), + valuations: DryRunResource.new(label: "Valuations", icon: "trending-up", text_class: "text-pink-500", bg_class: "bg-pink-500/5"), + budgets: DryRunResource.new(label: "Budgets", icon: "wallet", text_class: "text-indigo-500", bg_class: "bg-indigo-500/5"), + budget_categories: DryRunResource.new(label: "Budget Categories", icon: "pie-chart", text_class: "text-teal-500", bg_class: "bg-teal-500/5") } map[key] diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb index 33126717c..6f451d4a4 100644 --- a/app/helpers/languages_helper.rb +++ b/app/helpers/languages_helper.rb @@ -155,18 +155,19 @@ module LanguagesHelper # Locales with complete/extensive translations SUPPORTED_LOCALES = [ - "en", # English - 71 translation files - "fr", # French - 61 translation files - "de", # German - 62 translation files - "es", # Spanish - 61 translation files - "tr", # Turkish - 58 translation files - "nb", # Norwegian Bokmål - 57 translation files - "ca", # Catalan - 57 translation files - "ro", # Romanian - 62 translation files - "pt-BR", # Brazilian Portuguese - 60 translation files - "zh-CN", # Chinese (Simplified) - 59 translation files - "zh-TW", # Chinese (Traditional) - 63 translation files - "nl" # Dutch - 73 translation files + "en", # English + "fr", # French + "de", # German + "es", # Spanish + "tr", # Turkish + "nb", # Norwegian Bokmål + "ca", # Catalan + "ro", # Romanian + "pl", # Polish + "pt-BR", # Brazilian Portuguese + "zh-CN", # Chinese (Simplified) + "zh-TW", # Chinese (Traditional) + "nl" # Dutch ].freeze COUNTRY_MAPPING = { @@ -261,6 +262,7 @@ module LanguagesHelper KP: "🇰🇵 North Korea", KR: "🇰🇷 South Korea", KW: "🇰🇼 Kuwait", + XK: "🇽🇰 Kosovo", KG: "🇰🇬 Kyrgyzstan", LA: "🇱🇦 Laos", LV: "🇱🇻 Latvia", diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 78364ab7b..79a3c248a 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -4,6 +4,7 @@ module SettingsHelper { name: "Accounts", path: :accounts_path }, { name: "Bank Sync", path: :settings_bank_sync_path }, { name: "Preferences", path: :settings_preferences_path }, + { name: "Appearance", path: :settings_appearance_path }, { name: "Profile Info", path: :settings_profile_path }, { name: "Security", path: :settings_security_path }, { name: "Payment", path: :settings_payment_path, condition: :not_self_hosted? }, diff --git a/app/helpers/styled_form_builder.rb b/app/helpers/styled_form_builder.rb index f90888d72..bb51ab2ff 100644 --- a/app/helpers/styled_form_builder.rb +++ b/app/helpers/styled_form_builder.rb @@ -28,11 +28,30 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder end def collection_select(method, collection, value_method, text_method, options = {}, html_options = {}) - field_options = normalize_options(options, html_options) + selected_value = + if options.key?(:selected) + options[:selected] + elsif @object.respond_to?(method) + @object.public_send(method) + end + placeholder = options[:prompt] || options[:include_blank] || options[:placeholder] || I18n.t("helpers.select.default_label") - build_field(method, field_options, html_options) do |merged_html_options| - super(method, collection, value_method, text_method, options, merged_html_options) - end + @template.render( + DS::Select.new( + form: self, + method: method, + items: collection.map { |item| { value: item.public_send(value_method), label: item.public_send(text_method), object: item } }, + selected: selected_value, + placeholder: placeholder, + searchable: options.fetch(:searchable, false), + variant: options.fetch(:variant, :simple), + include_blank: options[:include_blank], + label: options[:label], + container_class: options[:container_class], + label_tooltip: options[:label_tooltip], + html_options: html_options + ) + ) end def money_field(amount_method, options = {}) diff --git a/app/javascript/controllers/admin_invitation_delete_controller.js b/app/javascript/controllers/admin_invitation_delete_controller.js new file mode 100644 index 000000000..e819d4200 --- /dev/null +++ b/app/javascript/controllers/admin_invitation_delete_controller.js @@ -0,0 +1,22 @@ +import { Controller } from "@hotwired/stimulus" + +// Connects to data-controller="admin-invitation-delete" +// Handles individual invitation deletion and alt-click to delete all family invitations +export default class extends Controller { + static targets = [ "button", "destroyAllForm" ] + static values = { deleteAllLabel: String } + + handleClick(event) { + if (event.altKey) { + event.preventDefault() + + this.buttonTargets.forEach(btn => { + btn.textContent = this.deleteAllLabelValue + }) + + if (this.hasDestroyAllFormTarget) { + this.destroyAllFormTarget.requestSubmit() + } + } + } +} diff --git a/app/javascript/controllers/attachment_upload_controller.js b/app/javascript/controllers/attachment_upload_controller.js new file mode 100644 index 000000000..ba1632d75 --- /dev/null +++ b/app/javascript/controllers/attachment_upload_controller.js @@ -0,0 +1,63 @@ +import { Controller } from "@hotwired/stimulus" + +export default class AttachmentUploadController extends Controller { + static targets = ["fileInput", "submitButton", "fileName", "uploadText"] + static values = { + maxFiles: Number, + maxSize: Number + } + + connect() { + this.updateSubmitButton() + } + + triggerFileInput() { + this.fileInputTarget.click() + } + + updateSubmitButton() { + const files = Array.from(this.fileInputTarget.files) + const hasFiles = files.length > 0 + + // Basic validation hints (server validates definitively) + let isValid = hasFiles + let errorMessage = "" + + if (hasFiles) { + if (this.hasUploadTextTarget) this.uploadTextTarget.classList.add("hidden") + if (this.hasFileNameTarget) { + const filenames = files.map(f => f.name).join(", ") + const textElement = this.fileNameTarget.querySelector("p") + if (textElement) textElement.textContent = filenames + this.fileNameTarget.classList.remove("hidden") + } + + // Check file count + if (files.length > this.maxFilesValue) { + isValid = false + errorMessage = `Too many files (max ${this.maxFilesValue})` + } + + // Check file sizes + const oversizedFiles = files.filter(file => file.size > this.maxSizeValue) + if (oversizedFiles.length > 0) { + isValid = false + errorMessage = `File too large (max ${Math.round(this.maxSizeValue / 1024 / 1024)}MB)` + } + } else { + if (this.hasUploadTextTarget) this.uploadTextTarget.classList.remove("hidden") + if (this.hasFileNameTarget) this.fileNameTarget.classList.add("hidden") + } + + this.submitButtonTarget.disabled = !isValid + + if (hasFiles && isValid) { + const count = files.length + this.submitButtonTarget.textContent = count === 1 ? "Upload 1 file" : `Upload ${count} files` + } else if (errorMessage) { + this.submitButtonTarget.textContent = errorMessage + } else { + this.submitButtonTarget.textContent = "Upload" + } + } +} diff --git a/app/javascript/controllers/bulk_select_controller.js b/app/javascript/controllers/bulk_select_controller.js index 0851da7ad..271b6a0f5 100644 --- a/app/javascript/controllers/bulk_select_controller.js +++ b/app/javascript/controllers/bulk_select_controller.js @@ -8,6 +8,7 @@ export default class extends Controller { "selectionBar", "selectionBarText", "bulkEditDrawerHeader", + "duplicateLink", ]; static values = { singularLabel: String, @@ -135,6 +136,18 @@ export default class extends Controller { this.selectionBarTarget.classList.toggle("hidden", count === 0); this.selectionBarTarget.querySelector("input[type='checkbox']").checked = count > 0; + + if (this.hasDuplicateLinkTarget) { + this.duplicateLinkTarget.classList.toggle("hidden", count !== 1); + if (count === 1) { + const url = new URL( + this.duplicateLinkTarget.href, + window.location.origin, + ); + url.searchParams.set("duplicate_entry_id", this.selectedIdsValue[0]); + this.duplicateLinkTarget.href = url.toString(); + } + } } _pluralizedResourceName() { diff --git a/app/javascript/controllers/categorize_controller.js b/app/javascript/controllers/categorize_controller.js new file mode 100644 index 000000000..08f5a1dc7 --- /dev/null +++ b/app/javascript/controllers/categorize_controller.js @@ -0,0 +1,165 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = [ + "list", + "createRuleCheckbox", + "filterDisplay", + "filterEditTrigger", + "filterEditArea", + "filterInput", + "groupingKeyHidden", + "filter", + "ruleDetails", + ]; + static values = { assignEntryUrl: String, position: Number, previewRuleUrl: String, transactionType: String }; + + connect() { + this.boundSelectFirst = this.selectFirst.bind(this); + document.addEventListener("keydown", this.boundSelectFirst); + this.toggleRuleDetails(); + } + + disconnect() { + document.removeEventListener("keydown", this.boundSelectFirst); + clearTimeout(this._previewTimer); + } + + selectFirst(event) { + if (event.key !== "Enter") return; + + const tag = event.target.tagName; + if (tag === "BUTTON" || tag === "A") return; + + // Don't intercept Enter when the user is confirming an inline filter edit + if (this.hasFilterInputTarget && event.target === this.filterInputTarget) return; + + event.preventDefault(); + + const visible = Array.from( + this.listTarget.querySelectorAll(".filterable-item") + ).filter((el) => el.style.display !== "none"); + + if (visible.length !== 1) return; + + visible[0].click(); + } + + clearFilter(event) { + if (event.target.tagName !== "BUTTON") return; + if (!this.hasFilterTarget) return; + this.filterTarget.value = ""; + this.filterTarget.dispatchEvent(new Event("input")); + } + + uncheckRule() { + if (this.hasCreateRuleCheckboxTarget) { + this.createRuleCheckboxTarget.checked = false; + this.toggleRuleDetails(); + } + } + + toggleRuleDetails() { + if (!this.hasRuleDetailsTarget || !this.hasCreateRuleCheckboxTarget) return; + const enabled = this.createRuleCheckboxTarget.checked; + this.ruleDetailsTarget.classList.toggle("opacity-40", !enabled); + if (this.hasFilterInputTarget) { + this.filterInputTarget.disabled = !enabled; + } + } + + startFilterEdit() { + this.filterDisplayTarget.classList.add("hidden"); + this.filterEditTriggerTarget.classList.add("hidden"); + this.filterEditAreaTarget.classList.remove("hidden"); + this.filterEditAreaTarget.classList.add("flex"); + this.filterInputTarget.focus(); + this.filterInputTarget.select(); + } + + confirmFilterEdit(event) { + event.preventDefault(); + event.stopPropagation(); + const value = this.filterInputTarget.value.trim(); + if (!value) return; + + this.filterDisplayTarget.textContent = `"${value}"`; + this.groupingKeyHiddenTarget.value = value; + + this.filterEditAreaTarget.classList.add("hidden"); + this.filterEditAreaTarget.classList.remove("flex"); + this.filterDisplayTarget.classList.remove("hidden"); + this.filterEditTriggerTarget.classList.remove("hidden"); + + this._doPreviewRule(value); + } + + cancelFilterEdit(event) { + event.preventDefault(); + event.stopPropagation(); + this.filterEditAreaTarget.classList.add("hidden"); + this.filterEditAreaTarget.classList.remove("flex"); + this.filterDisplayTarget.classList.remove("hidden"); + this.filterEditTriggerTarget.classList.remove("hidden"); + } + + previewRule(event) { + this._doPreviewRule(event.target.value); + } + + _doPreviewRule(filter) { + clearTimeout(this._previewTimer); + this._previewTimer = setTimeout(() => { + const url = new URL(this.previewRuleUrlValue, window.location.origin); + url.searchParams.set("filter", filter); + url.searchParams.set("position", this.positionValue); + url.searchParams.set("transaction_type", this.transactionTypeValue); + fetch(url.toString(), { + credentials: "same-origin", + headers: { + "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content, + Accept: "text/vnd.turbo-stream.html", + }, + }) + .then((r) => { if (!r.ok) throw new Error(r.statusText); return r.text(); }) + .then((html) => Turbo.renderStreamMessage(html)) + .catch((err) => console.error("Rule preview failed:", err)); + }, 300); + } + + assignEntry(event) { + const select = event.target; + const categoryId = select.value; + if (!categoryId) return; + + this.uncheckRule(); + + const entryId = select.dataset.entryId; + const body = new FormData(); + body.append("entry_id", entryId); + body.append("category_id", categoryId); + body.append("position", this.positionValue); + + // all_entry_ids[] hidden inputs live inside each Turbo Frame — + // automatically stay in sync as frames are removed + this.element.querySelectorAll("input[name='all_entry_ids[]']").forEach((input) => { + body.append("all_entry_ids[]", input.value); + }); + + fetch(this.assignEntryUrlValue, { + method: "PATCH", + credentials: "same-origin", + headers: { + "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content, + Accept: "text/vnd.turbo-stream.html", + }, + body, + }) + .then((r) => { if (!r.ok) throw new Error(r.statusText); return r.text(); }) + .then((html) => Turbo.renderStreamMessage(html)) + .catch((err) => { + console.error("Entry assignment failed:", err); + select.value = ""; + }); + } +} diff --git a/app/javascript/controllers/category_badge_select_controller.js b/app/javascript/controllers/category_badge_select_controller.js new file mode 100644 index 000000000..f9484f6e8 --- /dev/null +++ b/app/javascript/controllers/category_badge_select_controller.js @@ -0,0 +1,18 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["button"] + + updateButton(event) { + const { value } = event.detail + const option = this.element.querySelector(`[role="option"][data-value="${CSS.escape(value)}"]`) + if (!option) return + + const badge = option.querySelector("span.flex.items-center") + if (badge) { + this.buttonTarget.innerHTML = badge.outerHTML + } else { + this.buttonTarget.textContent = option.dataset.filterName || option.textContent.trim() + } + } +} diff --git a/app/javascript/controllers/coinstats_exchange_fields_controller.js b/app/javascript/controllers/coinstats_exchange_fields_controller.js new file mode 100644 index 000000000..bdfe45193 --- /dev/null +++ b/app/javascript/controllers/coinstats_exchange_fields_controller.js @@ -0,0 +1,63 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["select", "fields", "connectionName"] + static values = { + exchanges: Array, + initialConnectionId: String, + initialFields: Object + } + + connect() { + if (this.hasSelectTarget && this.initialConnectionIdValue && !this.selectTarget.value) { + this.selectTarget.value = this.initialConnectionIdValue + } + + this.render() + } + + render() { + if (!this.hasFieldsTarget || !this.hasSelectTarget) return + + const exchange = this.exchangesValue.find((entry) => entry.connection_id === this.selectTarget.value) + this.fieldsTarget.innerHTML = "" + + if (!exchange) { + if (this.hasConnectionNameTarget) this.connectionNameTarget.value = "" + return + } + + if (this.hasConnectionNameTarget) { + this.connectionNameTarget.value = exchange.name || "" + } + + const connectionFields = Array.isArray(exchange.connection_fields) ? exchange.connection_fields : [] + + connectionFields.forEach((field) => { + const wrapper = document.createElement("div") + wrapper.className = "space-y-1" + + const label = document.createElement("label") + label.className = "block text-sm font-medium text-primary" + label.setAttribute("for", `coinstats_exchange_${field.key}`) + label.textContent = field.name + + const input = document.createElement("input") + input.id = `coinstats_exchange_${field.key}` + input.name = `connection_fields[${field.key}]` + input.type = this.inputTypeFor(field.key) + input.autocomplete = "off" + input.className = "block w-full rounded-md border border-primary px-3 py-2 text-sm bg-container-inset text-primary placeholder:text-secondary focus:border-primary focus:ring-0" + input.placeholder = field.name + input.value = this.initialFieldsValue?.[field.key] || "" + + wrapper.appendChild(label) + wrapper.appendChild(input) + this.fieldsTarget.appendChild(wrapper) + }) + } + + inputTypeFor(key) { + return /secret|password|token|passphrase|private/i.test(key) ? "password" : "text" + } +} diff --git a/app/javascript/controllers/convert_to_trade_controller.js b/app/javascript/controllers/convert_to_trade_controller.js index 7b92abcca..44f66d965 100644 --- a/app/javascript/controllers/convert_to_trade_controller.js +++ b/app/javascript/controllers/convert_to_trade_controller.js @@ -1,4 +1,5 @@ import { Controller } from "@hotwired/stimulus" +import parseLocaleFloat from "utils/parse_locale_float" export default class extends Controller { static targets = ["customWrapper", "customField", "tickerSelect", "qtyField", "priceField", "priceWarning", "priceWarningMessage"] @@ -42,8 +43,8 @@ export default class extends Controller { // Calculate the implied/entered price let enteredPriceCents = null - const qty = Number.parseFloat(this.qtyFieldTarget?.value) - const enteredPrice = Number.parseFloat(this.priceFieldTarget?.value) + const qty = parseLocaleFloat(this.qtyFieldTarget?.value) + const enteredPrice = parseLocaleFloat(this.priceFieldTarget?.value) if (enteredPrice && enteredPrice > 0) { // User entered a price directly diff --git a/app/javascript/controllers/cost_basis_form_controller.js b/app/javascript/controllers/cost_basis_form_controller.js index 467cd5a9a..4f91f4fe0 100644 --- a/app/javascript/controllers/cost_basis_form_controller.js +++ b/app/javascript/controllers/cost_basis_form_controller.js @@ -1,4 +1,5 @@ import { Controller } from "@hotwired/stimulus" +import parseLocaleFloat from "utils/parse_locale_float" // Handles bidirectional conversion between total cost basis and per-share cost // in the manual cost basis entry form. @@ -9,7 +10,7 @@ export default class extends Controller { // Called when user types in the total cost basis field // Updates the per-share display and input to show the calculated value updatePerShare() { - const total = Number.parseFloat(this.totalTarget.value) || 0 + const total = parseLocaleFloat(this.totalTarget.value) const qty = this.qtyValue || 1 const perShare = qty > 0 ? (total / qty).toFixed(2) : "0.00" this.perShareValueTarget.textContent = perShare @@ -21,7 +22,7 @@ export default class extends Controller { // Called when user types in the per-share field // Updates the total cost basis field with the calculated value updateTotal() { - const perShare = Number.parseFloat(this.perShareTarget.value) || 0 + const perShare = parseLocaleFloat(this.perShareTarget.value) const qty = this.qtyValue || 1 const total = (perShare * qty).toFixed(2) this.totalTarget.value = total diff --git a/app/javascript/controllers/dashboard_sortable_controller.js b/app/javascript/controllers/dashboard_sortable_controller.js index 67d3cfa4e..e9ba9badf 100644 --- a/app/javascript/controllers/dashboard_sortable_controller.js +++ b/app/javascript/controllers/dashboard_sortable_controller.js @@ -3,9 +3,9 @@ import { Controller } from "@hotwired/stimulus"; export default class extends Controller { static targets = ["section", "handle"]; - // Short delay to prevent accidental touches on the grip handle + // Hold delay to require deliberate press-and-hold before activating drag mode static values = { - holdDelay: { type: Number, default: 150 }, + holdDelay: { type: Number, default: 800 }, }; connect() { @@ -22,6 +22,14 @@ export default class extends Controller { // ===== Mouse Drag Events ===== dragStart(event) { + // If a touch interaction is in progress, cancel native drag — + // use touch events with hold delay instead. + // This avoids blocking mouse/trackpad drag on touch-capable laptops. + if (this.isTouching || this.pendingSection) { + event.preventDefault(); + return; + } + this.draggedElement = event.currentTarget; this.draggedElement.classList.add("opacity-50"); this.draggedElement.setAttribute("aria-grabbed", "true"); @@ -88,6 +96,10 @@ export default class extends Controller { this.currentTouchY = this.touchStartY; this.holdActivated = false; + // Prevent text selection while waiting for hold to activate + section.style.userSelect = "none"; + section.style.webkitUserSelect = "none"; + // Start hold timer this.holdTimer = setTimeout(() => { this.activateDrag(); @@ -110,11 +122,25 @@ export default class extends Controller { } touchMove(event) { - if (!this.holdActivated || !this.isTouching || !this.draggedElement) return; + const touchX = event.touches[0].clientX; + const touchY = event.touches[0].clientY; + + // If hold hasn't activated yet, cancel if user moves too far (scrolling or swiping) + // Uses Euclidean distance to catch diagonal gestures too + if (!this.holdActivated) { + const dx = touchX - this.touchStartX; + const dy = touchY - this.touchStartY; + if (dx * dx + dy * dy > 100) { // 10px radius + this.cancelHold(); + } + return; + } + + if (!this.isTouching || !this.draggedElement) return; event.preventDefault(); - this.currentTouchX = event.touches[0].clientX; - this.currentTouchY = event.touches[0].clientY; + this.currentTouchX = touchX; + this.currentTouchY = touchY; const afterElement = this.getDragAfterElement(this.currentTouchX, this.currentTouchY); this.clearPlaceholders(); @@ -159,6 +185,16 @@ export default class extends Controller { } resetTouchState() { + // Restore text selection + if (this.pendingSection) { + this.pendingSection.style.userSelect = ""; + this.pendingSection.style.webkitUserSelect = ""; + } + if (this.draggedElement) { + this.draggedElement.style.userSelect = ""; + this.draggedElement.style.webkitUserSelect = ""; + } + this.isTouching = false; this.draggedElement = null; this.pendingSection = null; diff --git a/app/javascript/controllers/drawer_cost_basis_controller.js b/app/javascript/controllers/drawer_cost_basis_controller.js index 6091bc9cd..972db5940 100644 --- a/app/javascript/controllers/drawer_cost_basis_controller.js +++ b/app/javascript/controllers/drawer_cost_basis_controller.js @@ -1,4 +1,5 @@ import { Controller } from "@hotwired/stimulus" +import parseLocaleFloat from "utils/parse_locale_float" // Handles the inline cost basis editor in the holding drawer. // Shows/hides the form and handles bidirectional total <-> per-share conversion. @@ -13,7 +14,7 @@ export default class extends Controller { // Called when user types in total cost basis field updatePerShare() { - const total = Number.parseFloat(this.totalTarget.value) || 0 + const total = parseLocaleFloat(this.totalTarget.value) const qty = this.qtyValue || 1 const perShare = qty > 0 ? (total / qty).toFixed(2) : "0.00" this.perShareValueTarget.textContent = perShare @@ -24,7 +25,7 @@ export default class extends Controller { // Called when user types in per-share field updateTotal() { - const perShare = Number.parseFloat(this.perShareTarget.value) || 0 + const perShare = parseLocaleFloat(this.perShareTarget.value) const qty = this.qtyValue || 1 const total = (perShare * qty).toFixed(2) this.totalTarget.value = total diff --git a/app/javascript/controllers/form_dropdown_controller.js b/app/javascript/controllers/form_dropdown_controller.js new file mode 100644 index 000000000..d191106f8 --- /dev/null +++ b/app/javascript/controllers/form_dropdown_controller.js @@ -0,0 +1,18 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["input"] + + onSelect(event) { + this.inputTarget.value = event.detail.value + + const inputEvent = new Event("input", { bubbles: true }) + this.inputTarget.dispatchEvent(inputEvent) + + const form = this.element.closest("form") + const controllers = (form?.dataset.controller || "").split(/\s+/) + if (form && controllers.includes("auto-submit-form")) { + form.requestSubmit() + } + } +} diff --git a/app/javascript/controllers/list_filter_controller.js b/app/javascript/controllers/list_filter_controller.js index 279d8b47b..c6938a417 100644 --- a/app/javascript/controllers/list_filter_controller.js +++ b/app/javascript/controllers/list_filter_controller.js @@ -6,6 +6,8 @@ export default class extends Controller { connect() { this.inputTarget.focus(); + this.highlightedIndex = -1; + this.updateAriaActiveDescendant(); } filter() { @@ -30,5 +32,82 @@ export default class extends Controller { if (noMatchFound && this.hasEmptyMessageTarget) { this.emptyMessageTarget.classList.remove("hidden"); } + + this.highlightedIndex = -1; + this.clearHighlights(); + this.updateAriaActiveDescendant(); + } + + handleKeydown(event) { + if (event.key === "ArrowDown") { + event.preventDefault(); + this.highlightNext(); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + this.highlightPrevious(); + } else if (event.key === "Enter") { + event.preventDefault(); + this.selectHighlighted(); + } + } + + highlightNext() { + const items = this.visibleItems; + if (items.length === 0) return; + + this.clearHighlights(); + this.highlightedIndex = Math.min(this.highlightedIndex + 1, items.length - 1); + this.highlightItem(items[this.highlightedIndex]); + this.updateAriaActiveDescendant(); + } + + highlightPrevious() { + const items = this.visibleItems; + if (items.length === 0) return; + + this.clearHighlights(); + this.highlightedIndex = Math.max(this.highlightedIndex - 1, 0); + this.highlightItem(items[this.highlightedIndex]); + this.updateAriaActiveDescendant(); + } + + highlightItem(item) { + item.classList.add("bg-container-inset-hover"); + item.setAttribute("aria-selected", "true"); + item.scrollIntoView({ block: "nearest" }); + } + + clearHighlights() { + this.listTarget.querySelectorAll(".filterable-item").forEach((item) => { + item.classList.remove("bg-container-inset-hover"); + item.setAttribute("aria-selected", "false"); + }); + } + + selectHighlighted() { + const items = this.visibleItems; + if (this.highlightedIndex < 0 || this.highlightedIndex >= items.length) return; + + const item = items[this.highlightedIndex]; + const form = item.querySelector("form"); + if (form) { + form.requestSubmit(); + } + } + + updateAriaActiveDescendant() { + const items = this.visibleItems; + if (this.highlightedIndex >= 0 && this.highlightedIndex < items.length) { + const item = items[this.highlightedIndex]; + this.inputTarget.setAttribute("aria-activedescendant", item.id); + } else { + this.inputTarget.removeAttribute("aria-activedescendant"); + } + } + + get visibleItems() { + return Array.from(this.listTarget.querySelectorAll(".filterable-item")).filter( + (item) => item.style.display !== "none" + ); } } diff --git a/app/javascript/controllers/money_field_controller.js b/app/javascript/controllers/money_field_controller.js index 2aab2d16e..db8b1211b 100644 --- a/app/javascript/controllers/money_field_controller.js +++ b/app/javascript/controllers/money_field_controller.js @@ -1,10 +1,17 @@ import { Controller } from "@hotwired/stimulus"; import { CurrenciesService } from "services/currencies_service"; +import parseLocaleFloat from "utils/parse_locale_float"; // Connects to data-controller="money-field" // when currency select change, update the input value with the correct placeholder and step export default class extends Controller { static targets = ["amount", "currency", "symbol"]; + static values = { + precision: Number, + step: String, + }; + + requestSequence = 0; handleCurrencyChange(e) { const selectedCurrency = e.target.value; @@ -12,16 +19,33 @@ export default class extends Controller { } updateAmount(currency) { - new CurrenciesService().get(currency).then((currency) => { - this.amountTarget.step = currency.step; + const requestId = ++this.requestSequence; + new CurrenciesService().get(currency).then((currencyData) => { + if (requestId !== this.requestSequence) return; - if (Number.isFinite(this.amountTarget.value)) { - this.amountTarget.value = Number.parseFloat( - this.amountTarget.value, - ).toFixed(currency.default_precision); + this.amountTarget.step = + this.hasStepValue && + this.stepValue !== "" && + (this.stepValue === "any" || Number.isFinite(Number(this.stepValue))) + ? this.stepValue + : currencyData.step; + + const rawValue = this.amountTarget.value.trim(); + if (rawValue !== "") { + const parsedAmount = parseLocaleFloat(rawValue); + if (Number.isFinite(parsedAmount)) { + const precision = + this.hasPrecisionValue && Number.isInteger(this.precisionValue) + ? this.precisionValue + : currencyData.default_precision; + this.amountTarget.value = parsedAmount.toFixed(precision); + } } - this.symbolTarget.innerText = currency.symbol; + this.symbolTarget.innerText = currencyData.symbol; + }).catch(() => { + // Catch prevents Unhandled Promise Rejection for network failures. + // Silently ignored as they are unactionable by the user. }); } } diff --git a/app/javascript/controllers/plaid_controller.js b/app/javascript/controllers/plaid_controller.js index a12660f0a..e24031f77 100644 --- a/app/javascript/controllers/plaid_controller.js +++ b/app/javascript/controllers/plaid_controller.js @@ -10,11 +10,75 @@ export default class extends Controller { }; connect() { - this.open(); + this._connectionToken = (this._connectionToken ?? 0) + 1; + const connectionToken = this._connectionToken; + this.open(connectionToken).catch((error) => { + console.error("Failed to initialize Plaid Link", error); + }); } - open() { - const handler = Plaid.create({ + disconnect() { + this._handler?.destroy(); + this._handler = null; + this._connectionToken = (this._connectionToken ?? 0) + 1; + } + + waitForPlaid() { + if (typeof Plaid !== "undefined") { + return Promise.resolve(); + } + + return new Promise((resolve, reject) => { + let plaidScript = document.querySelector( + 'script[src*="link-initialize.js"]' + ); + + // Reject if the CDN request stalls without firing load or error + const timeoutId = window.setTimeout(() => { + if (plaidScript) plaidScript.dataset.plaidState = "error"; + reject(new Error("Timed out loading Plaid script")); + }, 10_000); + + // Remove previously failed script so we can retry with a fresh element + if (plaidScript?.dataset.plaidState === "error") { + plaidScript.remove(); + plaidScript = null; + } + + if (!plaidScript) { + plaidScript = document.createElement("script"); + plaidScript.src = "https://cdn.plaid.com/link/v2/stable/link-initialize.js"; + plaidScript.async = true; + plaidScript.dataset.plaidState = "loading"; + document.head.appendChild(plaidScript); + } + + plaidScript.addEventListener("load", () => { + window.clearTimeout(timeoutId); + plaidScript.dataset.plaidState = "loaded"; + resolve(); + }, { once: true }); + plaidScript.addEventListener("error", () => { + window.clearTimeout(timeoutId); + plaidScript.dataset.plaidState = "error"; + reject(new Error("Failed to load Plaid script")); + }, { once: true }); + + // Re-check after attaching listeners in case the script loaded between + // the initial typeof check and listener attachment (avoids a permanently + // pending promise on retry flows). + if (typeof Plaid !== "undefined") { + window.clearTimeout(timeoutId); + resolve(); + } + }); + } + + async open(connectionToken = this._connectionToken) { + await this.waitForPlaid(); + if (connectionToken !== this._connectionToken) return; + + this._handler = Plaid.create({ token: this.linkTokenValue, onSuccess: this.handleSuccess, onLoad: this.handleLoad, @@ -22,7 +86,7 @@ export default class extends Controller { onEvent: this.handleEvent, }); - handler.open(); + this._handler.open(); } handleSuccess = (public_token, metadata) => { diff --git a/app/javascript/controllers/polling_controller.js b/app/javascript/controllers/polling_controller.js index 97cc252c1..0e9e743f8 100644 --- a/app/javascript/controllers/polling_controller.js +++ b/app/javascript/controllers/polling_controller.js @@ -35,7 +35,7 @@ export default class extends Controller { try { const response = await fetch(this.urlValue, { headers: { - Accept: "text/vnd.turbo-stream.html", + Accept: "text/html", "Turbo-Frame": this.element.id, }, }); diff --git a/app/javascript/controllers/privacy_mode_controller.js b/app/javascript/controllers/privacy_mode_controller.js new file mode 100644 index 000000000..5d968e6c9 --- /dev/null +++ b/app/javascript/controllers/privacy_mode_controller.js @@ -0,0 +1,43 @@ +import { Controller } from "@hotwired/stimulus" + +// Privacy Mode Controller +// Toggles visibility of financial numbers across the page. +// Elements with class "privacy-sensitive" will be blurred when active. +// State persists in localStorage so it survives page navigations. +// A synchronous inline script in pre-applies the class to prevent +// a flash of unblurred content on first paint (see _privacy_mode_check.html.erb). +export default class extends Controller { + static targets = ["toggle", "iconOn", "iconOff"] + + connect() { + this.active = localStorage.getItem("privacyMode") === "true" + this._apply() + } + + toggle() { + this.active = !this.active + localStorage.setItem("privacyMode", this.active.toString()) + this._apply() + } + + _apply() { + if (this.active) { + document.documentElement.classList.add("privacy-mode") + } else { + document.documentElement.classList.remove("privacy-mode") + } + + // Update button state + this.toggleTargets.forEach((el) => { + el.setAttribute("aria-pressed", this.active.toString()) + }) + + // Toggle icon visibility: show eye when active (click to reveal), eye-off when inactive + this.iconOnTargets.forEach((el) => { + el.classList.toggle("hidden", !this.active) + }) + this.iconOffTargets.forEach((el) => { + el.classList.toggle("hidden", this.active) + }) + } +} \ No newline at end of file diff --git a/app/javascript/controllers/qif_date_format_controller.js b/app/javascript/controllers/qif_date_format_controller.js new file mode 100644 index 000000000..6c1044f58 --- /dev/null +++ b/app/javascript/controllers/qif_date_format_controller.js @@ -0,0 +1,15 @@ +import { Controller } from "@hotwired/stimulus"; + +// Updates the date preview text when the QIF date format dropdown changes. +// Previews are precomputed server-side and passed as a JSON value. +export default class extends Controller { + static targets = ["preview"]; + static values = { previews: Object }; + + change(event) { + const format = event.target.value; + const date = this.previewsValue[format]; + + this.previewTarget.textContent = date || ""; + } +} diff --git a/app/javascript/controllers/select_controller.js b/app/javascript/controllers/select_controller.js new file mode 100644 index 000000000..23b56051d --- /dev/null +++ b/app/javascript/controllers/select_controller.js @@ -0,0 +1,182 @@ +import { Controller } from "@hotwired/stimulus" +import { autoUpdate } from "@floating-ui/dom" + +export default class extends Controller { + static targets = ["button", "menu", "input"] + static values = { + placement: { type: String, default: "bottom-start" }, + offset: { type: Number, default: 6 } + } + + connect() { + this.isOpen = false + this.boundOutsideClick = this.handleOutsideClick.bind(this) + this.boundKeydown = this.handleKeydown.bind(this) + this.boundTurboLoad = this.handleTurboLoad.bind(this) + + document.addEventListener("click", this.boundOutsideClick) + document.addEventListener("turbo:load", this.boundTurboLoad) + this.element.addEventListener("keydown", this.boundKeydown) + + this.observeMenuResize() + } + + disconnect() { + document.removeEventListener("click", this.boundOutsideClick) + document.removeEventListener("turbo:load", this.boundTurboLoad) + this.element.removeEventListener("keydown", this.boundKeydown) + this.stopAutoUpdate() + if (this.resizeObserver) this.resizeObserver.disconnect() + } + + toggle = () => { + this.isOpen ? this.close() : this.openMenu() + } + + openMenu() { + this.isOpen = true + this.menuTarget.classList.remove("hidden") + this.buttonTarget.setAttribute("aria-expanded", "true") + this.startAutoUpdate() + this.clearSearch() + requestAnimationFrame(() => { + this.menuTarget.classList.remove("opacity-0", "-translate-y-1", "pointer-events-none") + this.menuTarget.classList.add("opacity-100", "translate-y-0") + this.updatePosition() + this.scrollToSelected() + }) + } + + close() { + this.isOpen = false + this.stopAutoUpdate() + this.menuTarget.classList.remove("opacity-100", "translate-y-0") + this.menuTarget.classList.add("opacity-0", "-translate-y-1", "pointer-events-none") + this.buttonTarget.setAttribute("aria-expanded", "false") + setTimeout(() => { if (!this.isOpen && this.hasMenuTarget) this.menuTarget.classList.add("hidden") }, 150) + } + + select(event) { + const selectedElement = event.currentTarget + const value = selectedElement.dataset.value + const label = selectedElement.dataset.filterName || selectedElement.textContent.trim() + + this.buttonTarget.textContent = label + if (this.hasInputTarget) { + this.inputTarget.value = value + this.inputTarget.dispatchEvent(new Event("change", { bubbles: true })) + } + + const previousSelected = this.menuTarget.querySelector("[aria-selected='true']") + if (previousSelected) { + previousSelected.setAttribute("aria-selected", "false") + previousSelected.classList.remove("bg-container-inset") + const prevIcon = previousSelected.querySelector(".check-icon") + if (prevIcon) prevIcon.classList.add("hidden") + } + + selectedElement.setAttribute("aria-selected", "true") + selectedElement.classList.add("bg-container-inset") + const selectedIcon = selectedElement.querySelector(".check-icon") + if (selectedIcon) selectedIcon.classList.remove("hidden") + + this.element.dispatchEvent(new CustomEvent("dropdown:select", { + detail: { value, label }, + bubbles: true + })) + + this.close() + this.buttonTarget.focus() + } + + focusSearch() { + const input = this.menuTarget.querySelector('input[type="search"]') + if (input) { input.focus({ preventScroll: true }); return true } + return false + } + + focusFirstElement() { + const selector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + const el = this.menuTarget.querySelector(selector) + if (el) el.focus({ preventScroll: true }) + } + + scrollToSelected() { + const selected = this.menuTarget.querySelector(".bg-container-inset") + if (selected) selected.scrollIntoView({ block: "center" }) + } + + handleOutsideClick(event) { + if (this.isOpen && !this.element.contains(event.target)) this.close() + } + + handleKeydown(event) { + if (!this.isOpen) return + if (event.key === "Escape") { this.close(); this.buttonTarget.focus() } + if (event.key === "Enter" && event.target.dataset.value) { event.preventDefault(); event.target.click() } + } + + handleTurboLoad() { if (this.isOpen) this.close() } + + clearSearch() { + const input = this.menuTarget.querySelector('input[type="search"]') + if (!input) return + input.value = "" + input.dispatchEvent(new Event("input", { bubbles: true })) + } + + startAutoUpdate() { + if (!this._cleanup && this.buttonTarget && this.menuTarget) { + this._cleanup = autoUpdate(this.buttonTarget, this.menuTarget, () => this.updatePosition()) + } + } + + stopAutoUpdate() { + if (this._cleanup) { this._cleanup(); this._cleanup = null } + } + + observeMenuResize() { + this.resizeObserver = new ResizeObserver(() => { + if (this.isOpen) requestAnimationFrame(() => this.updatePosition()) + }) + this.resizeObserver.observe(this.menuTarget) + } + + getScrollParent(element) { + let parent = element.parentElement + while (parent) { + const style = getComputedStyle(parent) + const overflowY = style.overflowY + if (overflowY === "auto" || overflowY === "scroll") return parent + parent = parent.parentElement + } + return document.documentElement + } + + updatePosition() { + if (!this.buttonTarget || !this.menuTarget || !this.isOpen) return + + const container = this.getScrollParent(this.element) + const containerRect = container.getBoundingClientRect() + const buttonRect = this.buttonTarget.getBoundingClientRect() + const menuHeight = this.menuTarget.scrollHeight + + const spaceBelow = containerRect.bottom - buttonRect.bottom + const spaceAbove = buttonRect.top - containerRect.top + const shouldOpenUp = spaceBelow < menuHeight && spaceAbove > spaceBelow + + this.menuTarget.style.left = "0" + this.menuTarget.style.width = "100%" + this.menuTarget.style.top = "" + this.menuTarget.style.bottom = "" + this.menuTarget.style.overflowY = "auto" + + if (shouldOpenUp) { + this.menuTarget.style.bottom = "100%" + this.menuTarget.style.maxHeight = `${Math.max(0, spaceAbove - this.offsetValue)}px` + } else { + this.menuTarget.style.top = "100%" + this.menuTarget.style.maxHeight = `${Math.max(0, spaceBelow - this.offsetValue)}px` + } + } +} \ No newline at end of file diff --git a/app/javascript/controllers/split_transaction_controller.js b/app/javascript/controllers/split_transaction_controller.js new file mode 100644 index 000000000..4eea5e1f1 --- /dev/null +++ b/app/javascript/controllers/split_transaction_controller.js @@ -0,0 +1,158 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["rowsContainer", "row", "amountInput", "remaining", "remainingContainer", "error", "submitButton", "nameInput"] + static values = { total: Number, currency: String } + + connect() { + this.updateRemaining() + } + + get rowCount() { + return this.rowTargets.length + } + + addRow() { + const index = this.rowCount + const container = this.rowsContainerTarget + + const row = document.createElement("div") + row.classList.add("p-3", "rounded-lg", "border", "border-secondary", "bg-container") + row.dataset.splitTransactionTarget = "row" + + // Clone category select from the first row + const existingCategorySelect = container.querySelector(".category-select-container") + let categorySelectHTML = "" + if (existingCategorySelect) { + const cloned = existingCategorySelect.cloneNode(true) + + // Reset hidden input value and update name + const hiddenInput = cloned.querySelector("input[type='hidden']") + if (hiddenInput) { + hiddenInput.value = "" + hiddenInput.name = `split[splits][${index}][category_id]` + } + + // Reset button to show placeholder text (uncategorized) + const button = cloned.querySelector("[data-select-target='button']") + if (button) { + // Find the uncategorized option text from the menu + const uncategorizedOption = cloned.querySelector("[data-value='']") + const placeholderText = uncategorizedOption ? uncategorizedOption.dataset.filterName : "(uncategorized)" + button.innerHTML = placeholderText + button.setAttribute("aria-expanded", "false") + } + + // Reset selected states in menu + cloned.querySelectorAll("[role='option']").forEach(option => { + option.setAttribute("aria-selected", "false") + option.classList.remove("bg-container-inset") + const checkIcon = option.querySelector(".check-icon") + if (checkIcon) checkIcon.classList.add("hidden") + }) + + // Select the blank/uncategorized option + const blankOption = cloned.querySelector("[data-value='']") + if (blankOption) { + blankOption.setAttribute("aria-selected", "true") + blankOption.classList.add("bg-container-inset") + const checkIcon = blankOption.querySelector(".check-icon") + if (checkIcon) checkIcon.classList.remove("hidden") + } + + // Ensure menu is hidden + const menu = cloned.querySelector("[data-select-target='menu']") + if (menu && !menu.classList.contains("hidden")) { + menu.classList.add("hidden") + } + + categorySelectHTML = cloned.outerHTML + } + + row.innerHTML = ` +
+
+ + +
+
+ + +
+ ${categorySelectHTML} + +
+ ` + + container.appendChild(row) + this.updateRemaining() + } + + removeRow(event) { + event.stopPropagation() + const row = event.target.closest("[data-split-transaction-target='row']") + if (row && this.rowCount > 1) { + row.remove() + this.reindexRows() + this.updateRemaining() + } + } + + reindexRows() { + this.rowTargets.forEach((row, index) => { + // Update input names (including hidden inputs inside category select) + row.querySelectorAll("[name]").forEach(input => { + input.name = input.name.replace(/splits\[\d+\]/, `splits[${index}]`) + }) + }) + } + + updateRemaining() { + const total = this.totalValue + const sum = this.amountInputTargets.reduce((acc, input) => { + return acc + (Number.parseFloat(input.value) || 0) + }, 0) + + const remaining = total - sum + const absRemaining = Math.abs(remaining) + const balanced = absRemaining < 0.005 + + this.remainingTarget.textContent = balanced ? "0.00" : remaining.toFixed(2) + + // Visual feedback on remaining balance + const container = this.remainingContainerTarget + + if (balanced) { + this.remainingTarget.classList.remove("text-destructive") + this.remainingTarget.classList.add("text-success") + container.classList.remove("border-destructive", "bg-red-tint-10") + container.classList.add("border-green-200", "bg-green-tint-10") + } else { + this.remainingTarget.classList.remove("text-success") + this.remainingTarget.classList.add("text-destructive") + container.classList.remove("border-green-200", "bg-green-tint-10") + container.classList.add("border-destructive", "bg-red-tint-10") + } + + this.errorTarget.classList.toggle("hidden", balanced) + this.submitButtonTarget.disabled = !balanced + } +} diff --git a/app/javascript/utils/parse_locale_float.js b/app/javascript/utils/parse_locale_float.js new file mode 100644 index 000000000..0011f5eab --- /dev/null +++ b/app/javascript/utils/parse_locale_float.js @@ -0,0 +1,37 @@ +// Parses a float from a string that may use either commas or dots as decimal separators. +// Handles formats like "1,234.56" (English) and "1.234,56" (French/European). +// +// When a `separator` hint is provided (e.g., from currency metadata), parsing is +// deterministic. Without a hint, a heuristic detects the format from the string. +export default function parseLocaleFloat(value, { separator } = {}) { + if (typeof value !== "string") return Number.parseFloat(value) || 0 + + const cleaned = value.replace(/\s/g, "") + + // Deterministic parsing when the currency's decimal separator is known + if (separator === ",") { + return Number.parseFloat(cleaned.replace(/\./g, "").replace(",", ".")) || 0 + } + if (separator === ".") { + return Number.parseFloat(cleaned.replace(/,/g, "")) || 0 + } + + // Heuristic: detect separator from the string when no hint is available + const lastComma = cleaned.lastIndexOf(",") + const lastDot = cleaned.lastIndexOf(".") + + if (lastComma > lastDot) { + // When there's no dot present and exactly 3 digits follow the last comma, + // treat comma as a thousands separator (e.g., "1,234" → 1234, "12,345" → 12345) + const digitsAfterComma = cleaned.length - lastComma - 1 + if (lastDot === -1 && digitsAfterComma === 3) { + return Number.parseFloat(cleaned.replace(/,/g, "")) || 0 + } + + // Comma is the decimal separator (e.g., "1.234,56" or "256,54") + return Number.parseFloat(cleaned.replace(/\./g, "").replace(",", ".")) || 0 + } + + // Dot is the decimal separator (e.g., "1,234.56" or "256.54") + return Number.parseFloat(cleaned.replace(/,/g, "")) || 0 +} diff --git a/app/jobs/data_cleaner_job.rb b/app/jobs/data_cleaner_job.rb index 8cb22f283..becf0fba5 100644 --- a/app/jobs/data_cleaner_job.rb +++ b/app/jobs/data_cleaner_job.rb @@ -3,6 +3,7 @@ class DataCleanerJob < ApplicationJob def perform clean_old_merchant_associations + clean_expired_archived_exports end private @@ -14,4 +15,10 @@ class DataCleanerJob < ApplicationJob Rails.logger.info("DataCleanerJob: Deleted #{deleted_count} old merchant associations") if deleted_count > 0 end + + def clean_expired_archived_exports + deleted_count = ArchivedExport.expired.destroy_all.count + + Rails.logger.info("DataCleanerJob: Deleted #{deleted_count} expired archived exports") if deleted_count > 0 + end end diff --git a/app/jobs/demo_family_refresh_job.rb b/app/jobs/demo_family_refresh_job.rb new file mode 100644 index 000000000..65f6a8c9b --- /dev/null +++ b/app/jobs/demo_family_refresh_job.rb @@ -0,0 +1,80 @@ +class DemoFamilyRefreshJob < ApplicationJob + queue_as :scheduled + + def perform + period_end = Time.current + period_start = period_end - 24.hours + + demo_email = Rails.application.config_for(:demo).fetch("email") + demo_user = User.find_by(email: demo_email) + old_family = demo_user&.family + + old_family_session_count = sessions_count_for(old_family, period_start:, period_end:) + newly_created_families_count = Family.where(created_at: period_start...period_end).count + + if old_family + delete_old_family_monitoring_key!(old_family) + anonymize_family_emails!(old_family) + DestroyJob.perform_later(old_family) + end + + Demo::Generator.new.generate_default_data!(skip_clear: true, email: demo_email) + + notify_super_admins!( + old_family:, + old_family_session_count:, + newly_created_families_count:, + period_start:, + period_end: + ) + end + + private + + def sessions_count_for(family, period_start:, period_end:) + return 0 unless family + + Session + .joins(:user) + .where(users: { family_id: family.id }) + .where(created_at: period_start...period_end) + .distinct + .count(:id) + end + + + def delete_old_family_monitoring_key!(family) + ApiKey + .where(user_id: family.users.select(:id), display_key: ApiKey::DEMO_MONITORING_KEY) + .delete_all + end + + def anonymize_family_emails!(family) + family.users.find_each do |user| + user.update_columns( + email: deleted_email_for(user), + unconfirmed_email: nil, + updated_at: Time.current + ) + end + end + + def deleted_email_for(user) + local_part, domain = user.email.split("@", 2) + "#{local_part}+deleting-#{user.id}-#{SecureRandom.hex(4)}@#{domain}" + end + + def notify_super_admins!(old_family:, old_family_session_count:, newly_created_families_count:, period_start:, period_end:) + User.super_admin.find_each do |super_admin| + DemoFamilyRefreshMailer.with( + super_admin:, + old_family_id: old_family&.id, + old_family_name: old_family&.name, + old_family_session_count:, + newly_created_families_count:, + period_start:, + period_end: + ).completed.deliver_later + end + end +end diff --git a/app/jobs/destroy_job.rb b/app/jobs/destroy_job.rb index 692585de6..8b5622423 100644 --- a/app/jobs/destroy_job.rb +++ b/app/jobs/destroy_job.rb @@ -1,5 +1,6 @@ class DestroyJob < ApplicationJob queue_as :low_priority + self.enqueue_after_transaction_commit = :never def perform(model) model.destroy diff --git a/app/jobs/enhance_provider_merchants_job.rb b/app/jobs/enhance_provider_merchants_job.rb new file mode 100644 index 000000000..76a63be07 --- /dev/null +++ b/app/jobs/enhance_provider_merchants_job.rb @@ -0,0 +1,9 @@ +class EnhanceProviderMerchantsJob < ApplicationJob + queue_as :medium_priority + + def perform(family) + ProviderMerchant::Enhancer.new(family).enhance + ensure + Rails.cache.delete("enhance_provider_merchants:#{family.id}") + end +end diff --git a/app/jobs/inactive_family_cleaner_job.rb b/app/jobs/inactive_family_cleaner_job.rb new file mode 100644 index 000000000..118d1c4c1 --- /dev/null +++ b/app/jobs/inactive_family_cleaner_job.rb @@ -0,0 +1,64 @@ +class InactiveFamilyCleanerJob < ApplicationJob + queue_as :scheduled + + BATCH_SIZE = 500 + ARCHIVE_EXPIRY = 90.days + + def perform(dry_run: false) + return unless Rails.application.config.app_mode.managed? + + families = Family.inactive_trial_for_cleanup.limit(BATCH_SIZE) + count = families.count + + if count == 0 + Rails.logger.info("InactiveFamilyCleanerJob: No inactive families to clean up") + return + end + + Rails.logger.info("InactiveFamilyCleanerJob: Found #{count} inactive families to clean up#{' (dry run)' if dry_run}") + + families.find_each do |family| + if family.requires_data_archive? + if dry_run + Rails.logger.info("InactiveFamilyCleanerJob: Would archive data for family #{family.id}") + else + archive_family_data(family) + end + end + + if dry_run + Rails.logger.info("InactiveFamilyCleanerJob: Would destroy family #{family.id} (created: #{family.created_at})") + else + Rails.logger.info("InactiveFamilyCleanerJob: Destroying family #{family.id} (created: #{family.created_at})") + family.destroy + end + end + + Rails.logger.info("InactiveFamilyCleanerJob: Completed cleanup of #{count} families#{' (dry run)' if dry_run}") + end + + private + + def archive_family_data(family) + export_data = Family::DataExporter.new(family).generate_export + email = family.users.order(:created_at).first&.email + + ActiveRecord::Base.transaction do + archive = ArchivedExport.create!( + email: email || "unknown", + family_name: family.name, + expires_at: ARCHIVE_EXPIRY.from_now + ) + + archive.export_file.attach( + io: export_data, + filename: "sure_archive_#{family.id}.zip", + content_type: "application/zip" + ) + + raise ActiveRecord::Rollback, "File attach failed" unless archive.export_file.attached? + + Rails.logger.info("InactiveFamilyCleanerJob: Archived data for family #{family.id} (email: #{email}, token_digest: #{archive.download_token_digest.first(8)}...)") + end + end +end diff --git a/app/jobs/simplefin_item/balances_only_job.rb b/app/jobs/simplefin_item/balances_only_job.rb index a34602bcd..17f15c87c 100644 --- a/app/jobs/simplefin_item/balances_only_job.rb +++ b/app/jobs/simplefin_item/balances_only_job.rb @@ -33,25 +33,10 @@ class SimplefinItem::BalancesOnlyJob < ApplicationJob target_id = ActionView::RecordIdentifier.dom_id(item) Turbo::StreamsChannel.broadcast_replace_to(item.family, target: target_id, html: card_html) - # Also refresh Manual Accounts so the CTA state and duplicates clear without refresh - begin - manual_accounts = item.family.accounts - .visible_manual - .order(:name) - if manual_accounts.any? - manual_html = ApplicationController.render( - partial: "accounts/index/manual_accounts", - formats: [ :html ], - locals: { accounts: manual_accounts } - ) - Turbo::StreamsChannel.broadcast_update_to(item.family, target: "manual-accounts", html: manual_html) - else - manual_html = ApplicationController.render(inline: '
') - Turbo::StreamsChannel.broadcast_replace_to(item.family, target: "manual-accounts", html: manual_html) - end - rescue => inner - Rails.logger.warn("SimpleFin BalancesOnlyJob manual-accounts broadcast failed: #{inner.class} - #{inner.message}") - end + # Broadcast a refresh signal instead of rendered HTML. Each user's browser + # re-fetches via their own authenticated request, so the manual accounts + # list is correctly scoped to the current user. + item.family.broadcast_refresh rescue => e Rails.logger.warn("SimpleFin BalancesOnlyJob broadcast failed: #{e.class} - #{e.message}") end diff --git a/app/mailers/demo_family_refresh_mailer.rb b/app/mailers/demo_family_refresh_mailer.rb new file mode 100644 index 000000000..f44d435eb --- /dev/null +++ b/app/mailers/demo_family_refresh_mailer.rb @@ -0,0 +1,16 @@ +class DemoFamilyRefreshMailer < ApplicationMailer + def completed + @super_admin = params.fetch(:super_admin) + @old_family_id = params[:old_family_id] + @old_family_name = params[:old_family_name] + @old_family_session_count = params.fetch(:old_family_session_count) + @newly_created_families_count = params.fetch(:newly_created_families_count) + @period_start = params.fetch(:period_start) + @period_end = params.fetch(:period_end) + + mail( + to: @super_admin.email, + subject: "Demo family refresh completed" + ) + end +end diff --git a/app/models/account.rb b/app/models/account.rb index cfb6b4478..8a31a8d5e 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -1,11 +1,17 @@ class Account < ApplicationRecord include AASM, Syncable, Monetizable, Chartable, Linkable, Enrichable, Anchorable, Reconcileable, TaxTreatable + before_validation :assign_default_owner, if: -> { owner_id.blank? } + validates :name, :balance, :currency, presence: true + validate :owner_belongs_to_family, if: -> { owner_id.present? && family_id.present? } belongs_to :family + belongs_to :owner, class_name: "User", optional: true belongs_to :import, optional: true + has_many :account_shares, dependent: :destroy + has_many :shared_users, through: :account_shares, source: :user has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping" has_many :entries, dependent: :destroy has_many :transactions, through: :entries, source: :entryable, source_type: "Transaction" @@ -13,6 +19,7 @@ class Account < ApplicationRecord has_many :trades, through: :entries, source: :entryable, source_type: "Trade" has_many :holdings, dependent: :destroy has_many :balances, dependent: :destroy + has_many :recurring_transactions, dependent: :destroy monetize :balance, :cash_balance @@ -36,6 +43,31 @@ class Account < ApplicationRecord manual.where.not(status: :pending_deletion) } + # All accounts a user can access (owned + shared with them) + scope :accessible_by, ->(user) { + left_joins(:account_shares) + .where("accounts.owner_id = :uid OR account_shares.user_id = :uid", uid: user.id) + .distinct + } + + # Accounts a user can write to (owned or shared with full_control) + scope :writable_by, ->(user) { + left_joins(:account_shares) + .where("accounts.owner_id = :uid OR (account_shares.user_id = :uid AND account_shares.permission = 'full_control')", uid: user.id) + .distinct + } + + # Accounts that count in a user's financial calculations + scope :included_in_finances_for, ->(user) { + left_joins(:account_shares) + .where( + "accounts.owner_id = :uid OR " \ + "(account_shares.user_id = :uid AND account_shares.include_in_finances = true)", + uid: user.id + ) + .distinct + } + has_one_attached :logo, dependent: :purge_later delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy @@ -79,7 +111,7 @@ class Account < ApplicationRecord super(attribute, options) end - def create_and_sync(attributes, skip_initial_sync: false) + def create_and_sync(attributes, skip_initial_sync: false, opening_balance_date: nil) attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty # Default cash_balance to balance unless explicitly provided (e.g., Crypto sets it to 0) attrs = attributes.dup @@ -91,8 +123,13 @@ class Account < ApplicationRecord account.save! manager = Account::OpeningBalanceManager.new(account) - result = manager.set_opening_balance(balance: initial_balance || account.balance) + result = manager.set_opening_balance( + balance: initial_balance || account.balance, + date: opening_balance_date + ) raise result.error if result.error + + account.auto_share_with_family! if account.family.share_all_by_default? end # Skip initial sync for linked accounts - the provider sync will handle balance creation @@ -135,8 +172,9 @@ class Account < ApplicationRecord end end + family = simplefin_account.simplefin_item.family attributes = { - family: simplefin_account.simplefin_item.family, + family: family, name: simplefin_account.name, balance: balance, cash_balance: cash_balance, @@ -162,8 +200,9 @@ class Account < ApplicationRecord cash_balance = balance + family = enable_banking_account.enable_banking_item.family attributes = { - family: enable_banking_account.enable_banking_item.family, + family: family, name: enable_banking_account.name, balance: balance, cash_balance: cash_balance, @@ -208,6 +247,25 @@ class Account < ApplicationRecord create_and_sync(attributes, skip_initial_sync: true) end + def create_from_binance_account(binance_account) + family = binance_account.binance_item.family + + attributes = { + family: family, + name: binance_account.name, + balance: (binance_account.current_balance || 0).to_d, + cash_balance: 0, + currency: binance_account.currency.presence || family.currency, + accountable_type: "Crypto", + accountable_attributes: { + subtype: "exchange", + tax_treatment: "taxable" + } + } + + create_and_sync(attributes, skip_initial_sync: true) + end + private @@ -241,12 +299,22 @@ class Account < ApplicationRecord end def logo_url - provider&.logo_url + if institution_domain.present? && Setting.brand_fetch_client_id.present? + logo_size = Setting.brand_fetch_logo_size + + "https://cdn.brandfetch.io/#{institution_domain}/icon/fallback/lettermark/w/#{logo_size}/h/#{logo_size}?c=#{Setting.brand_fetch_client_id}" + elsif provider&.logo_url.present? + provider.logo_url + elsif logo.attached? + Rails.application.routes.url_helpers.rails_blob_path(logo, only_path: true) + end end def destroy_later - mark_for_deletion! - DestroyJob.perform_later(self) + transaction do + mark_for_deletion! + DestroyJob.perform_later(self) + end end # Override destroy to handle error recovery for accounts @@ -271,6 +339,10 @@ class Account < ApplicationRecord .order(amount: :desc) end + def latest_provider_holdings_snapshot_date + holdings.where.not(account_provider_id: nil).maximum(:date) + end + def start_date first_entry_date = entries.minimum(:date) || Date.current first_entry_date - 1.day @@ -299,6 +371,14 @@ class Account < ApplicationRecord accountable_class.long_subtype_label_for(subtype) || accountable_class.display_name end + def supports_default? + depository? || credit_card? + end + + def eligible_for_transaction_default? + supports_default? && active? && !linked? + end + # Determines if this account supports manual trade entry # Investment accounts always support trades; Crypto only if subtype is "exchange" def supports_trades? @@ -307,6 +387,13 @@ class Account < ApplicationRecord false end + def traded_standard_securities + Security.where(id: holdings.select(:security_id)) + .standard + .distinct + .order(:ticker) + end + # The balance type determines which "component" of balance is being tracked. # This is primarily used for balance related calculations and updates. # @@ -325,4 +412,62 @@ class Account < ApplicationRecord raise "Unknown account type: #{accountable_type}" end end + + def owned_by?(user) + user.present? && owner_id == user.id + end + + def shared_with?(user) + return false if user.nil? + + owned_by?(user) || + if account_shares.loaded? + account_shares.any? { |s| s.user_id == user.id } + else + account_shares.exists?(user: user) + end + end + + def shared? + account_shares.any? + end + + def permission_for(user) + return :owner if owned_by?(user) + account_shares.find_by(user: user)&.permission&.to_sym + end + + def share_with!(user, permission: "read_only", include_in_finances: true) + account_shares.create!(user: user, permission: permission, include_in_finances: include_in_finances) + end + + def unshare_with!(user) + account_shares.where(user: user).destroy_all + end + + def auto_share_with_family! + records = family.users.where.not(id: owner_id).pluck(:id).map do |user_id| + { account_id: id, user_id: user_id, permission: "read_write", + include_in_finances: true, created_at: Time.current, updated_at: Time.current } + end + + AccountShare.insert_all(records, unique_by: %i[account_id user_id]) if records.any? + end + + private + + def assign_default_owner + return if owner.present? + + if Current.user.present? && Current.user.family_id == family_id + self.owner = Current.user + else + self.owner = family&.users&.find_by(role: %w[admin super_admin]) || family&.users&.order(:created_at)&.first + end + end + + def owner_belongs_to_family + return if User.where(id: owner_id, family_id: family_id).exists? + errors.add(:owner, :invalid, message: "must belong to the same family as the account") + end end diff --git a/app/models/account/chartable.rb b/app/models/account/chartable.rb index b4f2645e5..59bc85c2a 100644 --- a/app/models/account/chartable.rb +++ b/app/models/account/chartable.rb @@ -1,5 +1,6 @@ module Account::Chartable extend ActiveSupport::Concern + SPARKLINE_CACHE_VERSION = "v4" def favorable_direction classification == "asset" ? "up" : "down" @@ -20,14 +21,19 @@ module Account::Chartable interval: interval )) - builder.send("#{view}_series") + normalize_linked_investment_series(builder.send("#{view}_series")) end def sparkline_series - cache_key = family.build_cache_key("#{id}_sparkline", invalidate_on_data_updates: true) + cache_key = family.build_cache_key("#{id}_sparkline_#{SPARKLINE_CACHE_VERSION}", invalidate_on_data_updates: true) Rails.cache.fetch(cache_key, expires_in: 24.hours) do balance_series end end + + private + def normalize_linked_investment_series(series) + Balance::LinkedInvestmentSeriesNormalizer.new(account: self, series: series).normalize + end end diff --git a/app/models/account/market_data_importer.rb b/app/models/account/market_data_importer.rb index d00c22347..c6caf7980 100644 --- a/app/models/account/market_data_importer.rb +++ b/app/models/account/market_data_importer.rb @@ -51,7 +51,7 @@ class Account::MarketDataImporter def import_security_prices return unless Security.provider - account_securities = account.trades.map(&:security).uniq + account_securities = (account.trades.map(&:security) + account.current_holdings.map(&:security)).uniq return if account_securities.empty? @@ -68,10 +68,17 @@ class Account::MarketDataImporter private # Calculates the first date we require a price for the given security scoped to this account def first_required_price_date(security) - account.trades.with_entry - .where(security: security) - .where(entries: { account_id: account.id }) - .minimum("entries.date") + trade_start_date = account.trades.with_entry + .where(security: security) + .where(entries: { account_id: account.id }) + .minimum("entries.date") + + holding_start_date = + if account.holdings.where(security: security).where.not(account_provider_id: nil).exists? + account.start_date + end + + [ trade_start_date, holding_start_date ].compact.min end def needs_exchange_rates? @@ -83,6 +90,7 @@ class Account::MarketDataImporter end def foreign_account? + return false if account.family.nil? account.currency != account.family.currency end end diff --git a/app/models/account/sync_complete_event.rb b/app/models/account/sync_complete_event.rb index d26b62f83..79e8ec16c 100644 --- a/app/models/account/sync_complete_event.rb +++ b/app/models/account/sync_complete_event.rb @@ -16,16 +16,6 @@ class Account::SyncCompleteEvent locals: { account: account } ) - # Replace the groups this account belongs to in both desktop and mobile sidebars - sidebar_targets.each do |(tab, mobile_flag)| - account.broadcast_replace_to( - account.family, - target: account_group.dom_id(tab: tab, mobile: mobile_flag), - partial: "accounts/accountable_group", - locals: { account_group: account_group, open: true, all_tab: tab == :all, mobile: mobile_flag } - ) - end - # If this is a manual, unlinked account (i.e. not part of a Plaid Item), # trigger the family sync complete broadcast so net worth graph is updated unless account.linked? @@ -35,29 +25,4 @@ class Account::SyncCompleteEvent # Refresh entire account page (only applies if currently viewing this account) account.broadcast_refresh end - - private - # Returns an array of [tab, mobile?] tuples that should receive an update. - # We broadcast to both the classification-specific tab and the "all" tab, - # for desktop (mobile: false) and mobile (mobile: true) variants. - def sidebar_targets - return [] unless account_group.present? - - [ - [ account_group.classification.to_sym, false ], - [ :all, false ], - [ account_group.classification.to_sym, true ], - [ :all, true ] - ] - end - - def account_group - family_balance_sheet.account_groups.find do |group| - group.accounts.any? { |a| a.id == account.id } - end - end - - def family_balance_sheet - account.family.balance_sheet - end end diff --git a/app/models/account/syncer.rb b/app/models/account/syncer.rb index ab198a958..3b6a4c9b0 100644 --- a/app/models/account/syncer.rb +++ b/app/models/account/syncer.rb @@ -8,7 +8,7 @@ class Account::Syncer def perform_sync(sync) Rails.logger.info("Processing balances (#{account.linked? ? 'reverse' : 'forward'})") import_market_data - materialize_balances + materialize_balances(window_start_date: sync.window_start_date) end def perform_post_sync @@ -16,9 +16,9 @@ class Account::Syncer end private - def materialize_balances + def materialize_balances(window_start_date: nil) strategy = account.linked? ? :reverse : :forward - Balance::Materializer.new(account, strategy: strategy).materialize_balances + Balance::Materializer.new(account, strategy: strategy, window_start_date: window_start_date).materialize_balances end # Syncs all the exchange rates + security prices this account needs to display historical chart data diff --git a/app/models/account_share.rb b/app/models/account_share.rb new file mode 100644 index 000000000..8737df625 --- /dev/null +++ b/app/models/account_share.rb @@ -0,0 +1,47 @@ +class AccountShare < ApplicationRecord + belongs_to :account + belongs_to :user + + PERMISSIONS = %w[full_control read_write read_only].freeze + + validates :permission, inclusion: { in: PERMISSIONS } + validates :user_id, uniqueness: { scope: :account_id } + validate :cannot_share_with_owner + validate :user_in_same_family + + scope :with_permission, ->(permission) { where(permission: permission) } + + def full_control? + permission == "full_control" + end + + def read_write? + permission == "read_write" + end + + def read_only? + permission == "read_only" + end + + def can_annotate? + full_control? || read_write? + end + + def can_edit? + full_control? + end + + private + + def cannot_share_with_owner + if account && user && account.owner_id == user_id + errors.add(:user, "is already the owner of this account") + end + end + + def user_in_same_family + if account && user && user.family_id != account.family_id + errors.add(:user, "must be in the same family") + end + end +end diff --git a/app/models/archived_export.rb b/app/models/archived_export.rb new file mode 100644 index 000000000..fb0f48181 --- /dev/null +++ b/app/models/archived_export.rb @@ -0,0 +1,29 @@ +class ArchivedExport < ApplicationRecord + has_one_attached :export_file, dependent: :purge_later + + scope :expired, -> { where(expires_at: ...Time.current) } + + attr_reader :download_token + + before_create :set_download_token_digest + + def downloadable? + expires_at > Time.current && export_file.attached? + end + + def self.find_by_download_token!(token) + find_by!(download_token_digest: digest_token(token)) + end + + def self.digest_token(token) + OpenSSL::Digest::SHA256.hexdigest(token) + end + + private + + def set_download_token_digest + raw_token = SecureRandom.urlsafe_base64(24) + @download_token = raw_token + self.download_token_digest = self.class.digest_token(raw_token) + end +end diff --git a/app/models/assistant.rb b/app/models/assistant.rb index 582af9b0b..b07009396 100644 --- a/app/models/assistant.rb +++ b/app/models/assistant.rb @@ -36,7 +36,7 @@ module Assistant def implementation_for(chat) raise Error, "chat is required" if chat.blank? - type = chat.user&.family&.assistant_type.presence || "builtin" + type = ENV["ASSISTANT_TYPE"].presence || chat.user&.family&.assistant_type.presence || "builtin" REGISTRY.fetch(type) { REGISTRY["builtin"] } end end diff --git a/app/models/assistant/external.rb b/app/models/assistant/external.rb index 276595dad..a64888a6e 100644 --- a/app/models/assistant/external.rb +++ b/app/models/assistant/external.rb @@ -1,14 +1,110 @@ class Assistant::External < Assistant::Base + Config = Struct.new(:url, :token, :agent_id, :session_key, keyword_init: true) + MAX_CONVERSATION_MESSAGES = 20 + class << self def for_chat(chat) new(chat) end + + def configured? + config.url.present? && config.token.present? + end + + def available_for?(user) + configured? && allowed_user?(user) + end + + def allowed_user?(user) + allowed = ENV["EXTERNAL_ASSISTANT_ALLOWED_EMAILS"] + return true if allowed.blank? + return false if user&.email.blank? + + allowed.split(",").map { |e| e.strip.downcase }.include?(user.email.downcase) + end + + def config + Config.new( + url: ENV["EXTERNAL_ASSISTANT_URL"].presence || Setting.external_assistant_url.presence, + token: ENV["EXTERNAL_ASSISTANT_TOKEN"].presence || Setting.external_assistant_token.presence, + agent_id: ENV["EXTERNAL_ASSISTANT_AGENT_ID"].presence || Setting.external_assistant_agent_id.presence || "main", + session_key: ENV.fetch("EXTERNAL_ASSISTANT_SESSION_KEY", "agent:main:main") + ) + end end def respond_to(message) - stop_thinking - chat.add_error( - StandardError.new("External assistant (OpenClaw/WebSocket) is not yet implemented.") + response_completed = false + + unless self.class.configured? + raise Assistant::Error, + "External assistant is not configured. Set the URL and token in Settings > Self-Hosting or via environment variables." + end + + unless self.class.allowed_user?(chat.user) + raise Assistant::Error, "Your account is not authorized to use the external assistant." + end + + assistant_message = AssistantMessage.new( + chat: chat, + content: "", + ai_model: "external-agent" ) + + client = build_client + messages = build_conversation_messages + + model = client.chat( + messages: messages, + user: "sure-family-#{chat.user.family_id}" + ) do |text| + if assistant_message.content.blank? + stop_thinking + assistant_message.content = text + assistant_message.save! + else + assistant_message.append_text!(text) + end + end + + if assistant_message.new_record? + stop_thinking + raise Assistant::Error, "External assistant returned an empty response." + end + + response_completed = true + assistant_message.update!(ai_model: model) if model.present? + rescue Assistant::Error, ActiveRecord::ActiveRecordError => e + cleanup_partial_response(assistant_message) unless response_completed + stop_thinking + chat.add_error(e) + rescue => e + Rails.logger.error("[Assistant::External] Unexpected error: #{e.class} - #{e.message}") + cleanup_partial_response(assistant_message) unless response_completed + stop_thinking + chat.add_error(Assistant::Error.new("Something went wrong with the external assistant. Check server logs for details.")) end + + private + + def cleanup_partial_response(assistant_message) + assistant_message&.destroy! if assistant_message&.persisted? + rescue ActiveRecord::ActiveRecordError => e + Rails.logger.warn("[Assistant::External] Failed to clean up partial response: #{e.message}") + end + + def build_client + Assistant::External::Client.new( + url: self.class.config.url, + token: self.class.config.token, + agent_id: self.class.config.agent_id, + session_key: self.class.config.session_key + ) + end + + def build_conversation_messages + chat.conversation_messages.ordered.last(MAX_CONVERSATION_MESSAGES).map do |msg| + { role: msg.role, content: msg.content } + end + end end diff --git a/app/models/assistant/external/client.rb b/app/models/assistant/external/client.rb new file mode 100644 index 000000000..ec2559a3f --- /dev/null +++ b/app/models/assistant/external/client.rb @@ -0,0 +1,175 @@ +require "net/http" +require "uri" +require "json" + +class Assistant::External::Client + TIMEOUT_CONNECT = 10 # seconds + TIMEOUT_READ = 120 # seconds (agent may take time to reason + call tools) + MAX_RETRIES = 2 + RETRY_DELAY = 1 # seconds (doubles each retry) + MAX_SSE_BUFFER = 1_048_576 # 1 MB safety cap on SSE buffer + + TRANSIENT_ERRORS = [ + Net::OpenTimeout, + Net::ReadTimeout, + Errno::ECONNREFUSED, + Errno::ECONNRESET, + Errno::EHOSTUNREACH, + SocketError + ].freeze + + def initialize(url:, token:, agent_id: "main", session_key: "agent:main:main") + @url = url + @token = token # pipelock:ignore Credential in URL + @agent_id = agent_id + @session_key = session_key + end + + # Streams text chunks from an OpenAI-compatible chat endpoint via SSE. + # + # messages - Array of {role:, content:} hashes (conversation history) + # user - Optional user identifier for session persistence + # block - Called with each text chunk as it arrives + # + # Returns the model identifier string from the response. + def chat(messages:, user: nil, &block) + uri = URI(@url) + request = build_request(uri, messages, user) + retries = 0 + streaming_started = false + + begin + http = build_http(uri) + model = stream_response(http, request) do |content| + streaming_started = true + block.call(content) + end + model + rescue *TRANSIENT_ERRORS => e + if streaming_started + Rails.logger.warn("[External::Client] Stream interrupted: #{e.class} - #{e.message}") + raise Assistant::Error, "External assistant connection was interrupted." + end + + retries += 1 + if retries <= MAX_RETRIES + Rails.logger.warn("[External::Client] Transient error (attempt #{retries}/#{MAX_RETRIES}): #{e.class} - #{e.message}") + sleep(RETRY_DELAY * retries) + retry + end + Rails.logger.error("[External::Client] Unreachable after #{MAX_RETRIES + 1} attempts: #{e.class} - #{e.message}") + raise Assistant::Error, "External assistant is temporarily unavailable." + end + end + + private + + def stream_response(http, request, &block) + model = nil + buffer = +"" + done = false + + http.request(request) do |response| + unless response.is_a?(Net::HTTPSuccess) + Rails.logger.warn("[External::Client] Upstream HTTP #{response.code}: #{response.body.to_s.truncate(500)}") + raise Assistant::Error, "External assistant returned HTTP #{response.code}." + end + + response.read_body do |chunk| + break if done + buffer << chunk + + if buffer.bytesize > MAX_SSE_BUFFER + raise Assistant::Error, "External assistant stream exceeded maximum buffer size." + end + + while (line_end = buffer.index("\n")) + line = buffer.slice!(0..line_end).strip + next if line.empty? + next unless line.start_with?("data:") + + data = line.delete_prefix("data:") + data = data.delete_prefix(" ") # SSE spec: strip one optional leading space + + if data == "[DONE]" + done = true + break + end + + parsed = parse_sse_data(data) + next unless parsed + + model ||= parsed["model"] + content = parsed.dig("choices", 0, "delta", "content") + block.call(content) unless content.nil? + end + end + end + + model + end + + def build_http(uri) + proxy_uri = resolve_proxy(uri) + + if proxy_uri + http = Net::HTTP.new(uri.host, uri.port, proxy_uri.host, proxy_uri.port, proxy_uri.user, proxy_uri.password) + else + http = Net::HTTP.new(uri.host, uri.port) + end + + http.use_ssl = (uri.scheme == "https") + http.open_timeout = TIMEOUT_CONNECT + http.read_timeout = TIMEOUT_READ + http + end + + def resolve_proxy(uri) + proxy_env = (uri.scheme == "https") ? "HTTPS_PROXY" : "HTTP_PROXY" + proxy_url = ENV[proxy_env] || ENV[proxy_env.downcase] + return nil if proxy_url.blank? + + no_proxy = ENV["NO_PROXY"] || ENV["no_proxy"] + return nil if host_bypasses_proxy?(uri.host, no_proxy) + + URI(proxy_url) + rescue URI::InvalidURIError => e + Rails.logger.warn("[External::Client] Invalid proxy URL ignored: #{e.message}") + nil + end + + def host_bypasses_proxy?(host, no_proxy) + return false if no_proxy.blank? + host_down = host.downcase + no_proxy.split(",").any? do |pattern| + pattern = pattern.strip.downcase.delete_prefix(".") + host_down == pattern || host_down.end_with?(".#{pattern}") + end + end + + def build_request(uri, messages, user) + request = Net::HTTP::Post.new(uri.request_uri) + request["Content-Type"] = "application/json" + request["Authorization"] = "Bearer #{@token}" + request["Accept"] = "text/event-stream" + request["X-Agent-Id"] = @agent_id + request["X-Session-Key"] = @session_key + + payload = { + model: @agent_id, + messages: messages, + stream: true + } + payload[:user] = user if user.present? + + request.body = payload.to_json + request + end + + def parse_sse_data(data) + JSON.parse(data) + rescue JSON::ParserError => e + Rails.logger.warn("[External::Client] Unparseable SSE data: #{e.message}") + nil + end +end diff --git a/app/models/assistant/function.rb b/app/models/assistant/function.rb index 97f069ccc..4e918cb54 100644 --- a/app/models/assistant/function.rb +++ b/app/models/assistant/function.rb @@ -56,7 +56,7 @@ class Assistant::Function end def family_account_names - @family_account_names ||= family.accounts.visible.pluck(:name) + @family_account_names ||= user.accessible_accounts.visible.pluck(:name) end def family_category_names diff --git a/app/models/assistant/function/get_accounts.rb b/app/models/assistant/function/get_accounts.rb index 777b81493..13706d215 100644 --- a/app/models/assistant/function/get_accounts.rb +++ b/app/models/assistant/function/get_accounts.rb @@ -12,7 +12,7 @@ class Assistant::Function::GetAccounts < Assistant::Function def call(params = {}) { as_of_date: Date.current, - accounts: family.accounts.includes(:balances, :account_providers).map do |account| + accounts: user.accessible_accounts.includes(:balances, :account_providers).map do |account| { name: account.name, balance: account.balance, diff --git a/app/models/assistant/function/get_balance_sheet.rb b/app/models/assistant/function/get_balance_sheet.rb index b0e1f0e03..1d8846876 100644 --- a/app/models/assistant/function/get_balance_sheet.rb +++ b/app/models/assistant/function/get_balance_sheet.rb @@ -27,15 +27,15 @@ class Assistant::Function::GetBalanceSheet < Assistant::Function oldest_account_start_date: family.oldest_entry_date, currency: family.currency, net_worth: { - current: family.balance_sheet.net_worth_money.format, + current: family.balance_sheet(user: user).net_worth_money.format, monthly_history: historical_data(period) }, assets: { - current: family.balance_sheet.assets.total_money.format, + current: family.balance_sheet(user: user).assets.total_money.format, monthly_history: historical_data(period, classification: "asset") }, liabilities: { - current: family.balance_sheet.liabilities.total_money.format, + current: family.balance_sheet(user: user).liabilities.total_money.format, monthly_history: historical_data(period, classification: "liability") }, insights: insights_data @@ -44,7 +44,7 @@ class Assistant::Function::GetBalanceSheet < Assistant::Function private def historical_data(period, classification: nil) - scope = family.accounts.visible + scope = user.accessible_accounts.visible scope = scope.where(classification: classification) if classification.present? if period.start_date == Date.current @@ -65,8 +65,8 @@ class Assistant::Function::GetBalanceSheet < Assistant::Function end def insights_data - assets = family.balance_sheet.assets.total - liabilities = family.balance_sheet.liabilities.total + assets = family.balance_sheet(user: user).assets.total + liabilities = family.balance_sheet(user: user).liabilities.total ratio = liabilities.zero? ? 0 : (liabilities / assets.to_f) { diff --git a/app/models/assistant/function/get_holdings.rb b/app/models/assistant/function/get_holdings.rb index 4cc277072..d0025fb54 100644 --- a/app/models/assistant/function/get_holdings.rb +++ b/app/models/assistant/function/get_holdings.rb @@ -151,7 +151,7 @@ class Assistant::Function::GetHoldings < Assistant::Function end def investment_accounts - family.accounts.visible.where(accountable_type: SUPPORTED_ACCOUNT_TYPES) + user.accessible_accounts.visible.where(accountable_type: SUPPORTED_ACCOUNT_TYPES) end def investment_account_names diff --git a/app/models/assistant/function/search_family_files.rb b/app/models/assistant/function/search_family_files.rb index 2c0e5bf37..c9c917f0a 100644 --- a/app/models/assistant/function/search_family_files.rb +++ b/app/models/assistant/function/search_family_files.rb @@ -53,7 +53,10 @@ class Assistant::Function::SearchFamilyFiles < Assistant::Function query = params["query"] max_results = (params["max_results"] || 10).to_i.clamp(1, 20) + Rails.logger.debug("[SearchFamilyFiles] query=#{query.inspect} max_results=#{max_results} family_id=#{family.id}") + unless family.vector_store_id.present? + Rails.logger.debug("[SearchFamilyFiles] family #{family.id} has no vector_store_id") return { success: false, error: "no_documents", @@ -64,6 +67,7 @@ class Assistant::Function::SearchFamilyFiles < Assistant::Function adapter = VectorStore.adapter unless adapter + Rails.logger.debug("[SearchFamilyFiles] no VectorStore adapter configured") return { success: false, error: "provider_not_configured", @@ -71,48 +75,95 @@ class Assistant::Function::SearchFamilyFiles < Assistant::Function } end + store_id = family.vector_store_id + Rails.logger.debug("[SearchFamilyFiles] searching store_id=#{store_id} via #{adapter.class.name}") + + trace = create_langfuse_trace( + name: "search_family_files", + input: { query: query, max_results: max_results, store_id: store_id } + ) + response = adapter.search( - store_id: family.vector_store_id, + store_id: store_id, query: query, max_results: max_results ) unless response.success? + error_msg = response.error&.message + Rails.logger.debug("[SearchFamilyFiles] search failed: #{error_msg}") + begin + langfuse_client&.trace(id: trace.id, output: { error: error_msg }, level: "ERROR") if trace + rescue => e + Rails.logger.debug("[SearchFamilyFiles] Langfuse trace update failed: #{e.class}: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}") + end return { success: false, error: "search_failed", - message: "Failed to search documents: #{response.error&.message}" + message: "Failed to search documents: #{error_msg}" } end results = response.data - if results.empty? - return { - success: true, - results: [], - message: "No matching documents found for the query." - } + Rails.logger.debug("[SearchFamilyFiles] #{results.size} chunk(s) returned") + + results.each_with_index do |r, i| + Rails.logger.debug( + "[SearchFamilyFiles] chunk[#{i}] score=#{r[:score]} file=#{r[:filename].inspect} " \ + "content_length=#{r[:content]&.length} preview=#{r[:content]&.truncate(10).inspect}" + ) end - { - success: true, - query: query, - result_count: results.size, - results: results.map do |result| - { - content: result[:content], - filename: result[:filename], - score: result[:score] - } + mapped = results.map do |result| + { content: result[:content], filename: result[:filename], score: result[:score] } + end + + output = if mapped.empty? + { success: true, results: [], message: "No matching documents found for the query." } + else + { success: true, query: query, result_count: mapped.size, results: mapped } + end + + begin + if trace + langfuse_client&.trace(id: trace.id, output: { + result_count: mapped.size, + chunks: mapped.map { |r| { filename: r[:filename], score: r[:score], content_length: r[:content]&.length } } + }) end - } + rescue => e + Rails.logger.debug("[SearchFamilyFiles] Langfuse trace update failed: #{e.class}: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}") + end + + output rescue => e - Rails.logger.error("SearchFamilyFiles error: #{e.class.name} - #{e.message}") + Rails.logger.error("[SearchFamilyFiles] error: #{e.class.name} - #{e.message}") { success: false, error: "search_failed", message: "An error occurred while searching documents: #{e.message.truncate(200)}" } end + + private + def langfuse_client + return unless ENV["LANGFUSE_PUBLIC_KEY"].present? && ENV["LANGFUSE_SECRET_KEY"].present? + + @langfuse_client ||= Langfuse.new + end + + def create_langfuse_trace(name:, input:) + return unless langfuse_client + + langfuse_client.trace( + name: name, + input: input, + user_id: user.id&.to_s, + environment: Rails.env + ) + rescue => e + Rails.logger.debug("[SearchFamilyFiles] Langfuse trace creation failed: #{e.class}: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}") + nil + end end diff --git a/app/models/balance/forward_calculator.rb b/app/models/balance/forward_calculator.rb index af092a2a3..3e9956f60 100644 --- a/app/models/balance/forward_calculator.rb +++ b/app/models/balance/forward_calculator.rb @@ -1,11 +1,22 @@ class Balance::ForwardCalculator < Balance::BaseCalculator + def initialize(account, window_start_date: nil) + super(account) + @window_start_date = window_start_date + @fell_back = nil # unknown until calculate is called + end + + # True only when we are actually running in incremental mode (i.e. window_start_date + # was provided and we successfully found a valid prior balance to seed from). + # + # Must not be called before calculate — @fell_back is nil until resolve_starting_balances runs. + def incremental? + raise "incremental? must not be called before calculate" if @window_start_date.present? && @fell_back.nil? + @window_start_date.present? && @fell_back == false + end + def calculate Rails.logger.tagged("Balance::ForwardCalculator") do - start_cash_balance = derive_cash_balance_on_date_from_total( - total_balance: account.opening_anchor_balance, - date: account.opening_anchor_date - ) - start_non_cash_balance = account.opening_anchor_balance - start_cash_balance + start_cash_balance, start_non_cash_balance = resolve_starting_balances calc_start_date.upto(calc_end_date).map do |date| valuation = sync_cache.get_valuation(date) @@ -52,8 +63,67 @@ class Balance::ForwardCalculator < Balance::BaseCalculator end private + # Returns [start_cash_balance, start_non_cash_balance] for the first iteration. + # + # In incremental mode: load the persisted end-of-day balance for window_start_date - 1 + # from the DB and use that as the seed. Falls back to full recalculation when: + # - No prior balance record exists in the DB, or + # - The prior balance has a non-zero non-cash component (e.g. investment holdings) + # because Holding::Materializer always does a full recalc, which could make the + # persisted non-cash seed stale relative to freshly-computed holding prices. + def resolve_starting_balances + if @window_start_date.present? + if multi_currency_account? + Rails.logger.info("Account has multi-currency entries or is foreign, falling back to full recalculation") + @fell_back = true + return opening_starting_balances + end + + prior = prior_balance + + if prior && (prior.end_non_cash_balance || 0).zero? + Rails.logger.info("Incremental sync from #{@window_start_date}, seeding from persisted balance on #{prior.date}") + @fell_back = false + return [ prior.end_cash_balance, prior.end_non_cash_balance ] + elsif prior + Rails.logger.info("Prior balance has non-cash component, falling back to full recalculation") + else + Rails.logger.info("No persisted balance found for #{@window_start_date - 1}, falling back to full recalculation") + end + + @fell_back = true + end + + opening_starting_balances + end + + # Returns true when the account has entries in currencies other than the + # account currency, or when the account currency differs from the family + # currency. In either case, balance calculations depend on exchange rates + # that may have been missing (fallback_rate: 1) on a prior sync and later + # imported — so we must do a full recalculation to pick them up. + def multi_currency_account? + account.entries.where.not(currency: account.currency).exists? || + account.currency != account.family.currency + end + + def opening_starting_balances + cash = derive_cash_balance_on_date_from_total( + total_balance: account.opening_anchor_balance, + date: account.opening_anchor_date + ) + [ cash, account.opening_anchor_balance - cash ] + end + + # The balance record for the day immediately before the incremental window. + def prior_balance + account.balances + .where(currency: account.currency) + .find_by(date: @window_start_date - 1) + end + def calc_start_date - account.opening_anchor_date + incremental? ? @window_start_date : account.opening_anchor_date end def calc_end_date diff --git a/app/models/balance/linked_investment_series_normalizer.rb b/app/models/balance/linked_investment_series_normalizer.rb new file mode 100644 index 000000000..10b7aee36 --- /dev/null +++ b/app/models/balance/linked_investment_series_normalizer.rb @@ -0,0 +1,126 @@ +class Balance::LinkedInvestmentSeriesNormalizer + attr_reader :account, :series + + class << self + def aggregate_accounts(accounts:, currency:, period:, favorable_direction:, interval: "1 day") + accounts = Array(accounts) + account_ids = accounts.map(&:id) + + series = Balance::ChartSeriesBuilder.new( + account_ids: account_ids, + currency: currency, + period: period, + favorable_direction: favorable_direction, + interval: interval + ).balance_series + + common_start_date = common_supported_history_start_date(account_ids) + return series unless common_start_date.present? + + trimmed_values = series.values.select { |value| value.date >= common_start_date } + return series if trimmed_values.blank? || trimmed_values.length == series.values.length + + Series.new( + start_date: trimmed_values.first.date, + end_date: series.end_date, + interval: series.interval, + values: trimmed_values, + favorable_direction: series.favorable_direction + ) + end + + private + def common_supported_history_start_date(account_ids) + account_ids = Array(account_ids).compact + return if account_ids.empty? + + activity_dates = Entry.where(account_id: account_ids) + .where.not(source: nil) + .where.not(entryable_type: "Valuation") + .group(:account_id) + .minimum(:date) + + stable_holding_dates = stable_provider_holding_start_dates(account_ids) + + account_ids.filter_map do |account_id| + [ activity_dates[account_id], stable_holding_dates[account_id] ].compact.min + end.max + end + + def stable_provider_holding_start_dates(account_ids) + rows = Holding.where(account_id: account_ids) + .where.not(account_provider_id: nil) + .group(:account_id, :date) + .order(account_id: :asc, date: :desc) + .pluck(:account_id, :date, Arel.sql("array_agg(security_id ORDER BY security_id)")) + + rows.group_by(&:first).transform_values do |account_rows| + _account_id, latest_snapshot_date, latest_security_ids = account_rows.first + next unless latest_snapshot_date.present? + next latest_snapshot_date if latest_security_ids.blank? + + stable_dates = account_rows + .take_while { |_id, _date, security_ids| security_ids == latest_security_ids } + .map { |_id, date, _security_ids| date } + + stable_dates.last || latest_snapshot_date + end + end + end + + def initialize(account:, series:) + @account = account + @series = series + end + + def normalize + return series unless account.linked? && account.balance_type == :investment + + first_supported_history_date = supported_history_start_date + return series unless first_supported_history_date.present? + + trimmed_values = series.values.select { |value| value.date >= first_supported_history_date } + return series if trimmed_values.blank? || trimmed_values.length == series.values.length + + Series.new( + start_date: trimmed_values.first.date, + end_date: series.end_date, + interval: series.interval, + values: trimmed_values, + favorable_direction: series.favorable_direction + ) + end + + private + + def supported_history_start_date + [ first_provider_activity_date, stable_provider_holding_start_date ].compact.min + end + + def first_provider_activity_date + @first_provider_activity_date ||= account.entries + .where.not(source: nil) + .where.not(entryable_type: "Valuation") + .minimum(:date) + end + + def provider_holdings_scope + @provider_holdings_scope ||= account.holdings.where.not(account_provider_id: nil) + end + + def stable_provider_holding_start_date + date_security_pairs = provider_holdings_scope + .group(:date) + .order(date: :desc) + .pluck(:date, Arel.sql("array_agg(security_id ORDER BY security_id)")) + latest_snapshot_date, latest_security_ids = date_security_pairs.first + return unless latest_snapshot_date.present? + return latest_snapshot_date if latest_security_ids.blank? + + stable_dates = date_security_pairs + .take_while { |_date, security_ids| security_ids == latest_security_ids } + .map(&:first) + + stable_dates.last || latest_snapshot_date + end +end diff --git a/app/models/balance/materializer.rb b/app/models/balance/materializer.rb index c6501ffa1..4a2066454 100644 --- a/app/models/balance/materializer.rb +++ b/app/models/balance/materializer.rb @@ -1,9 +1,11 @@ class Balance::Materializer - attr_reader :account, :strategy + attr_reader :account, :strategy, :security_ids - def initialize(account, strategy:) + def initialize(account, strategy:, security_ids: nil, window_start_date: nil) @account = account @strategy = strategy + @security_ids = security_ids + @window_start_date = window_start_date end def materialize_balances @@ -24,7 +26,7 @@ class Balance::Materializer private def materialize_holdings - @holdings = Holding::Materializer.new(account, strategy: strategy).materialize_holdings + @holdings = Holding::Materializer.new(account, strategy: strategy, security_ids: security_ids).materialize_holdings end def update_account_info @@ -73,17 +75,44 @@ class Balance::Materializer def purge_stale_balances sorted_balances = @balances.sort_by(&:date) - oldest_calculated_balance_date = sorted_balances.first&.date - newest_calculated_balance_date = sorted_balances.last&.date - deleted_count = account.balances.delete_by("date < ? OR date > ?", oldest_calculated_balance_date, newest_calculated_balance_date) + + if sorted_balances.empty? + # In incremental forward-sync, even when no balances were calculated for the window + # (e.g. window_start_date is beyond the last entry), purge stale tail records that + # now fall beyond the prior-balance boundary so orphaned future rows are cleaned up. + if strategy == :forward && calculator.incremental? && account.opening_anchor_date <= @window_start_date - 1 + deleted_count = account.balances.delete_by( + "date < ? OR date > ?", + account.opening_anchor_date, + @window_start_date - 1 + ) + Rails.logger.info("Purged #{deleted_count} stale balances") if deleted_count > 0 + end + return + end + + newest_calculated_balance_date = sorted_balances.last.date + + # In incremental forward-sync mode the calculator only recalculates from + # window_start_date onward, so balances before that date are still valid. + # Use opening_anchor_date as the lower purge bound to preserve them. + # We ask the calculator whether it actually ran incrementally — it may have + # fallen back to a full recalculation, in which case we use the normal bound. + oldest_valid_date = if strategy == :forward && calculator.incremental? + account.opening_anchor_date + else + sorted_balances.first.date + end + + deleted_count = account.balances.delete_by("date < ? OR date > ?", oldest_valid_date, newest_calculated_balance_date) Rails.logger.info("Purged #{deleted_count} stale balances") if deleted_count > 0 end def calculator - if strategy == :reverse + @calculator ||= if strategy == :reverse Balance::ReverseCalculator.new(account) else - Balance::ForwardCalculator.new(account) + Balance::ForwardCalculator.new(account, window_start_date: @window_start_date) end end end diff --git a/app/models/balance/series_aggregator.rb b/app/models/balance/series_aggregator.rb new file mode 100644 index 000000000..fdf4d9dad --- /dev/null +++ b/app/models/balance/series_aggregator.rb @@ -0,0 +1,86 @@ +class Balance::SeriesAggregator + attr_reader :series_list, :favorable_direction, :currency, :align_to_common_start + + def initialize(series_list:, currency:, favorable_direction:, align_to_common_start: false) + @series_list = Array(series_list).compact + @currency = currency + @favorable_direction = favorable_direction + @align_to_common_start = align_to_common_start + end + + def aggregate + return empty_series if normalized_series_list.empty? + + values_by_date = normalized_series_list.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |series, hash| + series.values.each do |value| + hash[value.date] << value + end + end + + dates = values_by_date.keys.sort + return empty_series if dates.empty? + + previous_value = nil + values = dates.map do |date| + current_value = Money.new( + values_by_date[date].sum { |value| value.value.amount }, + currency + ) + + series_value = Series::Value.new( + date: date, + date_formatted: I18n.l(date, format: :long), + value: current_value, + trend: Trend.new( + current: current_value, + previous: previous_value, + favorable_direction: favorable_direction + ) + ) + + previous_value = current_value + series_value + end + + Series.new( + start_date: values.first.date, + end_date: values.last.date, + interval: normalized_series_list.first.interval, + values: values, + favorable_direction: favorable_direction + ) + end + + private + def normalized_series_list + @normalized_series_list ||= begin + return series_list unless align_to_common_start + + common_start_date = series_list.map(&:start_date).compact.max + return series_list if common_start_date.blank? + + series_list.filter_map do |series| + trimmed_values = series.values.select { |value| value.date >= common_start_date } + next if trimmed_values.blank? + + Series.new( + start_date: trimmed_values.first.date, + end_date: trimmed_values.last.date, + interval: series.interval, + values: trimmed_values, + favorable_direction: series.favorable_direction + ) + end + end + end + + def empty_series + Series.new( + start_date: Date.current, + end_date: Date.current, + interval: "1 day", + values: [], + favorable_direction: favorable_direction + ) + end +end diff --git a/app/models/balance/sync_cache.rb b/app/models/balance/sync_cache.rb index aed2b64e7..a6e12c9ee 100644 --- a/app/models/balance/sync_cache.rb +++ b/app/models/balance/sync_cache.rb @@ -4,22 +4,30 @@ class Balance::SyncCache end def get_valuation(date) - converted_entries.find { |e| e.date == date && e.valuation? } + entries_by_date[date]&.find { |e| e.valuation? } end def get_holdings(date) - converted_holdings.select { |h| h.date == date } + holdings_by_date[date] || [] end def get_entries(date) - converted_entries.select { |e| e.date == date && (e.transaction? || e.trade?) } + entries_by_date[date]&.select { |e| e.transaction? || e.trade? } || [] end private attr_reader :account + def entries_by_date + @entries_by_date ||= converted_entries.group_by(&:date) + end + + def holdings_by_date + @holdings_by_date ||= converted_holdings.group_by(&:date) + end + def converted_entries - @converted_entries ||= account.entries.order(:date).to_a.map do |e| + @converted_entries ||= account.entries.excluding_split_parents.order(:date).to_a.map do |e| converted_entry = e.dup converted_entry.amount = converted_entry.amount_money.exchange_to( account.currency, diff --git a/app/models/balance_sheet.rb b/app/models/balance_sheet.rb index e8f5f8442..6dd2db3ac 100644 --- a/app/models/balance_sheet.rb +++ b/app/models/balance_sheet.rb @@ -3,10 +3,11 @@ class BalanceSheet monetize :net_worth - attr_reader :family + attr_reader :family, :user - def initialize(family) + def initialize(family, user: nil) @family = family + @user = user || Current.user end def assets @@ -55,15 +56,15 @@ class BalanceSheet end def account_totals - @account_totals ||= AccountTotals.new(family, sync_status_monitor: sync_status_monitor) + @account_totals ||= AccountTotals.new(family, user: user, sync_status_monitor: sync_status_monitor) end def net_worth_series_builder - @net_worth_series_builder ||= NetWorthSeriesBuilder.new(family) + @net_worth_series_builder ||= NetWorthSeriesBuilder.new(family, user: user) end def sorted(accounts) - account_order = Current.user&.account_order + account_order = user&.account_order order_key = account_order&.key || "name_asc" case order_key diff --git a/app/models/balance_sheet/account_totals.rb b/app/models/balance_sheet/account_totals.rb index 2a1f63761..d03340423 100644 --- a/app/models/balance_sheet/account_totals.rb +++ b/app/models/balance_sheet/account_totals.rb @@ -1,6 +1,7 @@ class BalanceSheet::AccountTotals - def initialize(family, sync_status_monitor:) + def initialize(family, user: nil, sync_status_monitor:) @family = family + @user = user @sync_status_monitor = sync_status_monitor end @@ -13,10 +14,11 @@ class BalanceSheet::AccountTotals end private - attr_reader :family, :sync_status_monitor + attr_reader :family, :user, :sync_status_monitor - AccountRow = Data.define(:account, :converted_balance, :is_syncing) do + AccountRow = Data.define(:account, :converted_balance, :is_syncing, :included_in_finances) do def syncing? = is_syncing + def included_in_finances? = included_in_finances # Allows Rails path helpers to generate URLs from the wrapper def to_param = account.to_param @@ -24,7 +26,19 @@ class BalanceSheet::AccountTotals end def visible_accounts - @visible_accounts ||= family.accounts.visible.with_attached_logo + @visible_accounts ||= begin + scope = family.accounts.visible.with_attached_logo.includes(:account_shares) + scope = scope.accessible_by(user) if user + scope + end + end + + def finance_account_ids + @finance_account_ids ||= if user + family.accounts.included_in_finances_for(user).pluck(:id).to_set + else + nil + end end # Wraps each account in an AccountRow with its converted balance and sync status. @@ -33,15 +47,17 @@ class BalanceSheet::AccountTotals AccountRow.new( account: account, converted_balance: converted_balance_for(account), - is_syncing: sync_status_monitor.account_syncing?(account) + is_syncing: sync_status_monitor.account_syncing?(account), + included_in_finances: finance_account_ids.nil? || finance_account_ids.include?(account.id) ) end end # Returns the cache key for storing visible account IDs, invalidated on data updates. def cache_key + shares_version = user ? AccountShare.where(user: user).maximum(:updated_at)&.to_i : nil family.build_cache_key( - "balance_sheet_account_ids", + [ "balance_sheet_account_ids", user&.id, shares_version ].compact.join("_"), invalidate_on_data_updates: true ) end diff --git a/app/models/balance_sheet/classification_group.rb b/app/models/balance_sheet/classification_group.rb index 32e64214c..968d4b6c0 100644 --- a/app/models/balance_sheet/classification_group.rb +++ b/app/models/balance_sheet/classification_group.rb @@ -21,7 +21,7 @@ class BalanceSheet::ClassificationGroup end def total - accounts.sum(&:converted_balance) + accounts.select { |a| a.respond_to?(:included_in_finances?) ? a.included_in_finances? : true }.sum(&:converted_balance) end def syncing? diff --git a/app/models/balance_sheet/net_worth_series_builder.rb b/app/models/balance_sheet/net_worth_series_builder.rb index f3e61a995..7c29a6ece 100644 --- a/app/models/balance_sheet/net_worth_series_builder.rb +++ b/app/models/balance_sheet/net_worth_series_builder.rb @@ -1,6 +1,7 @@ class BalanceSheet::NetWorthSeriesBuilder - def initialize(family) + def initialize(family, user: nil) @family = family + @user = user end def net_worth_series(period: Period.last_30_days) @@ -17,15 +18,22 @@ class BalanceSheet::NetWorthSeriesBuilder end private - attr_reader :family + attr_reader :family, :user def visible_account_ids - @visible_account_ids ||= family.accounts.visible.with_attached_logo.pluck(:id) + @visible_account_ids ||= begin + scope = family.accounts.visible + scope = scope.included_in_finances_for(user) if user + scope.pluck(:id) + end end def cache_key(period) + shares_version = user ? AccountShare.where(user: user).maximum(:updated_at)&.to_i : nil key = [ "balance_sheet_net_worth_series", + user&.id, + shares_version, period.start_date, period.end_date ].compact.join("_") diff --git a/app/models/binance_account.rb b/app/models/binance_account.rb new file mode 100644 index 000000000..14749894b --- /dev/null +++ b/app/models/binance_account.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class BinanceAccount < ApplicationRecord + include CurrencyNormalizable, Encryptable + + STABLECOINS = %w[USDT BUSD FDUSD TUSD USDC DAI].freeze + + if encryption_ready? + encrypts :raw_payload + encrypts :raw_transactions_payload + end + + belongs_to :binance_item + + has_one :account_provider, as: :provider, dependent: :destroy + has_one :account, through: :account_provider, source: :account + has_one :linked_account, through: :account_provider, source: :account + + validates :name, :currency, presence: true + + def current_account + account + end + + def ensure_account_provider!(linked_account = nil) + acct = linked_account || current_account + return nil unless acct + + AccountProvider + .find_or_initialize_by(provider_type: "BinanceAccount", provider_id: id) + .tap do |ap| + ap.account = acct + ap.save! + end + rescue StandardError => e + Rails.logger.warn("BinanceAccount #{id}: failed to link account provider — #{e.class}: #{e.message}") + nil + end +end diff --git a/app/models/binance_account/holdings_processor.rb b/app/models/binance_account/holdings_processor.rb new file mode 100644 index 000000000..8eaf0e289 --- /dev/null +++ b/app/models/binance_account/holdings_processor.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +# Creates/updates Holdings for each asset in the combined BinanceAccount. +# One Holding per (symbol, source) pair. +class BinanceAccount::HoldingsProcessor + include BinanceAccount::UsdConverter + + def initialize(binance_account) + @binance_account = binance_account + end + + def process + unless account&.accountable_type == "Crypto" + Rails.logger.info "BinanceAccount::HoldingsProcessor - skipping: not a Crypto account" + return + end + + assets = raw_assets + if assets.empty? + Rails.logger.info "BinanceAccount::HoldingsProcessor - no assets in payload" + return + end + + assets.each { |asset| process_asset(asset) } + rescue StandardError => e + Rails.logger.error "BinanceAccount::HoldingsProcessor - error: #{e.message}" + nil + end + + private + + attr_reader :binance_account + + def target_currency + binance_account.binance_item.family.currency + end + + def account + binance_account.current_account + end + + def raw_assets + binance_account.raw_payload&.dig("assets") || [] + end + + def process_asset(asset) + symbol = asset["symbol"] || asset[:symbol] + return if symbol.blank? + + total = (asset["total"] || asset[:total]).to_d + source = asset["source"] || asset[:source] + + return if total.zero? + + ticker = symbol.include?(":") ? symbol : "CRYPTO:#{symbol}" + security = resolve_security(ticker, symbol) + return unless security + + price_usd = fetch_price(symbol) + return if price_usd.nil? + + amount_usd = total * price_usd + + # Stale rate metadata is intentionally discarded here — it is captured and + # surfaced at the account level by BinanceAccount::Processor#process_account!. + amount, _stale, _rate_date = convert_from_usd(amount_usd, date: Date.current) + + # Also convert per-unit price to target currency + price, _, _ = convert_from_usd(price_usd, date: Date.current) + + import_adapter.import_holding( + security: security, + quantity: total, + amount: amount, + currency: target_currency, + date: Date.current, + price: price, + cost_basis: nil, + external_id: "binance_#{symbol}_#{source}_#{Date.current}", + account_provider_id: binance_account.account_provider&.id, + source: "binance", + delete_future_holdings: false + ) + + Rails.logger.info "BinanceAccount::HoldingsProcessor - imported #{total} #{symbol} (#{source}) @ #{price_usd} USD → #{amount} #{target_currency}" + rescue StandardError => e + Rails.logger.error "BinanceAccount::HoldingsProcessor - failed asset #{asset}: #{e.message}" + end + + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + + def resolve_security(ticker, symbol) + BinanceAccount::SecurityResolver.resolve(ticker, symbol) + end + + def fetch_price(symbol) + return 1.0 if BinanceAccount::STABLECOINS.include?(symbol) + + provider = binance_account.binance_item&.binance_provider + return nil unless provider + + %w[USDT BUSD FDUSD].each do |quote| + price_str = provider.get_spot_price("#{symbol}#{quote}") + return price_str.to_d if price_str.present? + end + + Rails.logger.warn "BinanceAccount::HoldingsProcessor - no price found for #{symbol} across all quote pairs; skipping holding" + nil + end +end diff --git a/app/models/binance_account/processor.rb b/app/models/binance_account/processor.rb new file mode 100644 index 000000000..540383ec1 --- /dev/null +++ b/app/models/binance_account/processor.rb @@ -0,0 +1,260 @@ +# frozen_string_literal: true + +# Updates account balance and imports spot trades. +class BinanceAccount::Processor + include BinanceAccount::UsdConverter + + # Quote currencies probed when fetching trade history. Ordered by prevalence so + # the most common pairs are tried first and rate-limit weight is front-loaded. + TRADE_QUOTE_CURRENCIES = %w[USDT BUSD FDUSD BTC ETH BNB].freeze + + attr_reader :binance_account + + def initialize(binance_account) + @binance_account = binance_account + end + + def process + unless binance_account.current_account.present? + Rails.logger.info "BinanceAccount::Processor - no linked account for #{binance_account.id}, skipping" + return + end + + begin + BinanceAccount::HoldingsProcessor.new(binance_account).process + rescue StandardError => e + Rails.logger.error "BinanceAccount::Processor - holdings failed for #{binance_account.id}: #{e.message}" + end + + begin + process_account! + rescue StandardError => e + Rails.logger.error "BinanceAccount::Processor - account update failed for #{binance_account.id}: #{e.message}" + raise + end + + fetch_and_process_trades + end + + private + + def target_currency + binance_account.binance_item.family.currency + end + + def process_account! + account = binance_account.current_account + raw_usd = (binance_account.current_balance || 0).to_d + amount, stale, rate_date = convert_from_usd(raw_usd, date: Date.current) + stale_extra = build_stale_extra(stale, rate_date, Date.current) + + account.update!( + balance: amount, + cash_balance: 0, + currency: target_currency + ) + + binance_account.update!(extra: binance_account.extra.to_h.deep_merge(stale_extra)) + end + + def fetch_and_process_trades + provider = binance_account.binance_item&.binance_provider + return unless provider + + symbols = extract_trade_symbols + return if symbols.empty? + + existing_spot = binance_account.raw_transactions_payload&.dig("spot") || {} + new_trades_by_symbol = {} + + symbols.each do |symbol| + TRADE_QUOTE_CURRENCIES.each do |quote| + pair = "#{symbol}#{quote}" + begin + new_trades = fetch_new_trades(provider, pair, existing_spot[pair]) + new_trades_by_symbol[pair] = new_trades if new_trades.present? + rescue Provider::Binance::InvalidSymbolError => e + # Pair doesn't exist on Binance for this quote currency — expected, skip silently + Rails.logger.debug "BinanceAccount::Processor - skipping #{pair}: #{e.message}" + end + # ApiError, AuthenticationError and RateLimitError propagate so the sync is marked failed + end + end + + merged_spot = existing_spot.merge(new_trades_by_symbol) { |_pair, old, new_t| old + new_t } + binance_account.update!(raw_transactions_payload: { + "spot" => merged_spot, + "fetched_at" => Time.current.iso8601 + }) + + process_trades(new_trades_by_symbol) + end + + # Fetches only trades newer than what is already cached for the given pair. + # On the first sync (no cached trades) fetches the most recent page. + # On subsequent syncs starts from max_cached_id + 1 and paginates forward. + def fetch_new_trades(provider, pair, cached_trades) + limit = 1000 + max_cached_id = cached_trades&.map { |t| t["id"].to_i }&.max + + from_id = max_cached_id ? max_cached_id + 1 : nil + all_new = [] + + loop do + page = provider.get_spot_trades(pair, limit: limit, from_id: from_id) + break if page.blank? + + all_new.concat(page) + break if page.size < limit + + from_id = page.map { |t| t["id"].to_i }.max + 1 + end + + all_new + end + + def extract_trade_symbols + stablecoins = BinanceAccount::STABLECOINS + quote_re = /(#{TRADE_QUOTE_CURRENCIES.join("|")})$/ + + # Base symbols from today's asset snapshot + assets = binance_account.raw_payload&.dig("assets") || [] + current = assets.map { |a| a["symbol"] || a[:symbol] }.compact + + # Base symbols from previously fetched pairs (recovers sold-out assets) + prev_pairs = binance_account.raw_transactions_payload&.dig("spot")&.keys || [] + previous = prev_pairs.map { |pair| pair.gsub(quote_re, "") } + + (current + previous).uniq.compact.reject { |s| s.blank? || stablecoins.include?(s) } + end + + def process_trades(trades_by_symbol) + trades_by_symbol.each do |pair, trades| + trades.each { |trade| process_spot_trade(trade, pair) } + end + rescue StandardError => e + Rails.logger.error "BinanceAccount::Processor - trade processing failed: #{e.message}" + end + + def process_spot_trade(trade, pair) + account = binance_account.current_account + return unless account + + quote_suffix = TRADE_QUOTE_CURRENCIES.find { |q| pair.end_with?(q) } + base_symbol = quote_suffix ? pair.delete_suffix(quote_suffix) : pair + return if base_symbol.blank? + + ticker = "CRYPTO:#{base_symbol}" + security = BinanceAccount::SecurityResolver.resolve(ticker, base_symbol) + + return unless security + + external_id = "binance_spot_#{pair}_#{trade["id"]}" + return if account.entries.exists?(external_id: external_id) + + date = Time.zone.at(trade["time"].to_i / 1000).to_date + qty = trade["qty"].to_d + price_raw = trade["price"].to_d + quote_qty = trade["quoteQty"].to_d + + # quoteQty and price are denominated in the quote currency (e.g. BTC for ETHBTC). + # Convert to USD so all entries and cost-basis calculations share a common currency. + quote_symbol = quote_suffix || "USDT" + amount_usd_raw = quote_to_usd(quote_qty, quote_symbol, date: date) + price_usd = quote_to_usd(price_raw, quote_symbol, date: date) + + if amount_usd_raw.nil? || price_usd.nil? + Rails.logger.warn "BinanceAccount::Processor - skipping trade #{trade["id"]} for #{pair}: could not convert #{quote_symbol} to USD" + return + end + + amount_usd = amount_usd_raw.round(2) + commission = commission_in_usd(trade, base_symbol, price_usd, date: date) + is_buyer = trade["isBuyer"] + + if is_buyer + account.entries.create!( + date: date, + name: "Buy #{qty.round(8)} #{base_symbol}", + amount: -amount_usd, + currency: "USD", + external_id: external_id, + source: "binance", + entryable: Trade.new( + security: security, + qty: qty, + price: price_usd, + currency: "USD", + fee: commission, + investment_activity_label: "Buy" + ) + ) + else + account.entries.create!( + date: date, + name: "Sell #{qty.round(8)} #{base_symbol}", + amount: amount_usd, + currency: "USD", + external_id: external_id, + source: "binance", + entryable: Trade.new( + security: security, + qty: -qty, + price: price_usd, + currency: "USD", + fee: commission, + investment_activity_label: "Sell" + ) + ) + end + rescue StandardError => e + Rails.logger.error "BinanceAccount::Processor - failed to process trade #{trade["id"]}: #{e.message}" + end + + # Converts an amount denominated in quote_symbol to USD. + # Stablecoins are treated as 1:1; others use historical price when date is given, + # falling back to current USDT spot price. + def quote_to_usd(amount, quote_symbol, date: nil) + return amount if BinanceAccount::STABLECOINS.include?(quote_symbol) + + provider = binance_account.binance_item&.binance_provider + return nil unless provider + + spot = nil + spot = provider.get_historical_price("#{quote_symbol}USDT", date) if date.present? && provider.respond_to?(:get_historical_price) + spot ||= provider.get_spot_price("#{quote_symbol}USDT") + return nil if spot.nil? + + (amount * spot.to_d).round(8) + rescue StandardError => e + Rails.logger.warn "BinanceAccount::Processor - could not convert #{quote_symbol} to USD: #{e.message}" + nil + end + + # Converts the trade commission to USD. + # commissionAsset can be: a stablecoin (≈ 1 USD), the base asset, or something else (e.g. BNB). + def commission_in_usd(trade, base_symbol, trade_price, date: nil) + raw = trade["commission"].to_d + commission_asset = trade["commissionAsset"].to_s.upcase + return 0 if raw.zero? || commission_asset.blank? + + stablecoins = BinanceAccount::STABLECOINS + return raw if stablecoins.include?(commission_asset) + + # Fee in base asset (e.g. BTC for BTCUSDT) — convert using trade price + return (raw * trade_price).round(8) if commission_asset == base_symbol + + # Fee in another asset (typically BNB) — fetch current USDT spot price as approximation + provider = binance_account.binance_item&.binance_provider + return 0 unless provider + + spot = nil + spot = provider.get_historical_price("#{commission_asset}USDT", date) if date.present? && provider.respond_to?(:get_historical_price) + spot ||= provider.get_spot_price("#{commission_asset}USDT") + + (raw * spot.to_d).round(8) + rescue StandardError => e + Rails.logger.warn "BinanceAccount::Processor - could not convert commission for #{trade["id"]}: #{e.message}" + 0 + end +end diff --git a/app/models/binance_account/security_resolver.rb b/app/models/binance_account/security_resolver.rb new file mode 100644 index 000000000..133819c1a --- /dev/null +++ b/app/models/binance_account/security_resolver.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +# Resolves or creates a Security for a given Binance ticker. +# First attempts Security::Resolver; on failure, falls back to find_or_initialize_by +# and saves an offline security so syncs are not blocked by provider outages. +class BinanceAccount::SecurityResolver + EXCHANGE_MIC = "XBNC" + + def self.resolve(ticker, symbol) + result = Security::Resolver.new(ticker).resolve + if result.nil? + Rails.logger.debug "BinanceAccount::SecurityResolver - primary resolver returned nil for #{ticker}" + end + result + rescue StandardError => e + Rails.logger.warn "BinanceAccount::SecurityResolver - resolver failed for #{ticker}: #{e.message}" + Security.find_or_initialize_by(ticker: ticker, exchange_operating_mic: EXCHANGE_MIC).tap do |sec| + sec.name = symbol if sec.name.blank? + sec.offline = true unless sec.offline + sec.save! if sec.changed? + end + end +end diff --git a/app/models/binance_account/usd_converter.rb b/app/models/binance_account/usd_converter.rb new file mode 100644 index 000000000..405a94775 --- /dev/null +++ b/app/models/binance_account/usd_converter.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +# Shared currency conversion helpers for Binance processors. +# Converts USD amounts to the family's configured base currency using +# ExchangeRate.find_or_fetch_rate (which has a built-in 5-day nearest-rate lookback). +# When a fallback or no rate is used, sets a stale flag in account.extra["binance"]. +module BinanceAccount::UsdConverter + private + + # Converts a USD amount to target_currency on the given date. + # @return [Array(BigDecimal, Boolean, Date|nil)] + # [converted_amount, stale, rate_date_used] + # stale is false when the exact date rate was found, true otherwise. + # rate_date_used is nil when exact rate was used or no rate found. + def convert_from_usd(amount, date: Date.current) + return [ amount, false, nil ] if target_currency == "USD" + + rate = ExchangeRate.find_or_fetch_rate(from: "USD", to: target_currency, date: date) + + if rate.nil? + return [ amount.to_d, true, nil ] + end + + converted = Money.new(amount, "USD").exchange_to(target_currency, fallback_rate: rate.rate).amount + stale = rate.date != date + rate_date = stale ? rate.date : nil + + [ converted, stale, rate_date ] + end + + # Builds the hash to deep-merge into account.extra. + def build_stale_extra(stale, rate_date, target_date) + binance_meta = if stale + { + "stale_rate" => true, + "rate_date_used" => rate_date&.to_s, + "rate_target_date" => target_date.to_s + } + else + { "stale_rate" => false } + end + + { "binance" => binance_meta } + end +end diff --git a/app/models/binance_item.rb b/app/models/binance_item.rb new file mode 100644 index 000000000..b482cd501 --- /dev/null +++ b/app/models/binance_item.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +class BinanceItem < ApplicationRecord + include Syncable, Provided, Unlinking, Encryptable + + enum :status, { good: "good", requires_update: "requires_update" }, default: :good + + # Encrypt sensitive credentials if ActiveRecord encryption is configured + # api_key uses deterministic encryption for querying, api_secret uses standard encryption + if encryption_ready? + encrypts :api_key, deterministic: true + encrypts :api_secret + end + + validates :name, presence: true + validates :api_key, presence: true + validates :api_secret, presence: true + + belongs_to :family + has_one_attached :logo, dependent: :purge_later + + has_many :binance_accounts, dependent: :destroy + has_many :accounts, through: :binance_accounts + + scope :active, -> { where(scheduled_for_deletion: false) } + scope :syncable, -> { active } + scope :ordered, -> { order(created_at: :desc) } + scope :needs_update, -> { where(status: :requires_update) } + + def destroy_later + update!(scheduled_for_deletion: true) + DestroyJob.perform_later(self) + end + + def import_latest_binance_data + provider = binance_provider + unless provider + raise StandardError, "Binance credentials not configured" + end + + BinanceItem::Importer.new(self, binance_provider: provider).import + rescue StandardError => e + Rails.logger.error "BinanceItem #{id} - Failed to import: #{e.message}" + raise + end + + def process_accounts + Rails.logger.info "BinanceItem #{id} - process_accounts: total binance_accounts=#{binance_accounts.count}" + + return [] if binance_accounts.empty? + + binance_accounts.each do |ba| + Rails.logger.info( + "BinanceItem #{id} - binance_account #{ba.id}: " \ + "name='#{ba.name}' " \ + "account_provider=#{ba.account_provider&.id || 'nil'} " \ + "account=#{ba.account&.id || 'nil'}" + ) + end + + linked = binance_accounts.joins(:account).merge(Account.visible) + Rails.logger.info "BinanceItem #{id} - found #{linked.count} linked visible accounts to process" + + results = [] + + linked.each do |ba| + begin + Rails.logger.info "BinanceItem #{id} - processing binance_account #{ba.id}" + result = BinanceAccount::Processor.new(ba).process + results << { binance_account_id: ba.id, success: true, result: result } + rescue StandardError => e + Rails.logger.error "BinanceItem #{id} - Failed to process account #{ba.id}: #{e.message}" + Rails.logger.error e.backtrace.first(5).join("\n") + results << { binance_account_id: ba.id, success: false, error: e.message } + end + end + + results + end + + def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil) + return [] if accounts.empty? + + results = [] + accounts.visible.each do |account| + begin + account.sync_later( + parent_sync: parent_sync, + window_start_date: window_start_date, + window_end_date: window_end_date + ) + results << { account_id: account.id, success: true } + rescue StandardError => e + Rails.logger.error "BinanceItem #{id} - Failed to schedule sync for account #{account.id}: #{e.message}" + results << { account_id: account.id, success: false, error: e.message } + end + end + + results + end + + def upsert_binance_snapshot!(payload) + update!(raw_payload: payload) + end + + def has_completed_initial_setup? + accounts.any? + end + + def sync_status_summary + total = total_accounts_count + linked = linked_accounts_count + unlinked = unlinked_accounts_count + + if total == 0 + I18n.t("binance_items.binance_item.sync_status.no_accounts") + elsif unlinked == 0 + I18n.t("binance_items.binance_item.sync_status.all_synced", count: linked) + else + I18n.t("binance_items.binance_item.sync_status.partial_sync", linked_count: linked, unlinked_count: unlinked) + end + end + + def stale_rate_accounts + binance_accounts + .joins(:account) + .where(accounts: { status: "active" }) + .where("binance_accounts.extra -> 'binance' ->> 'stale_rate' = 'true'") + end + + def linked_accounts_count + binance_accounts.joins(:account_provider).count + end + + def unlinked_accounts_count + binance_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).count + end + + def total_accounts_count + binance_accounts.count + end + + def institution_display_name + institution_name.presence || institution_domain.presence || name + end + + def credentials_configured? + api_key.present? && api_secret.present? + end + + def set_binance_institution_defaults! + update!( + institution_name: "Binance", + institution_domain: "binance.com", + institution_url: "https://www.binance.com", + institution_color: "#F0B90B" + ) + end +end diff --git a/app/models/binance_item/earn_importer.rb b/app/models/binance_item/earn_importer.rb new file mode 100644 index 000000000..a6d761ec3 --- /dev/null +++ b/app/models/binance_item/earn_importer.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +# Fetches Binance Simple Earn (flexible + locked) positions. +# Merges both into a single asset list with source tag "earn". +class BinanceItem::EarnImporter + attr_reader :binance_item, :provider + + def initialize(binance_item, provider:) + @binance_item = binance_item + @provider = provider + end + + def import + flexible_raw = fetch_flexible + locked_raw = fetch_locked + + assets = merge_earn_assets( + parse_flexible(flexible_raw), + parse_locked(locked_raw) + ) + + { + assets: assets, + raw: { "flexible" => flexible_raw, "locked" => locked_raw }, + source: "earn" + } + rescue => e + Rails.logger.error "BinanceItem::EarnImporter #{binance_item.id} - #{e.message}" + { assets: [], raw: nil, source: "earn", error: e.message } + end + + private + + def fetch_flexible + provider.get_simple_earn_flexible + rescue => e + Rails.logger.warn "BinanceItem::EarnImporter #{binance_item.id} - flexible failed: #{e.message}" + nil + end + + def fetch_locked + provider.get_simple_earn_locked + rescue => e + Rails.logger.warn "BinanceItem::EarnImporter #{binance_item.id} - locked failed: #{e.message}" + nil + end + + def parse_flexible(raw) + return {} unless raw.is_a?(Hash) + + (raw["rows"] || []).each_with_object({}) do |row, acc| + symbol = row["asset"] + amount = row["totalAmount"].to_d + acc[symbol] = (acc[symbol] || 0) + amount + end + end + + def parse_locked(raw) + return {} unless raw.is_a?(Hash) + + (raw["rows"] || []).each_with_object({}) do |row, acc| + symbol = row["asset"] + amount = row["amount"].to_d + acc[symbol] = (acc[symbol] || 0) + amount + end + end + + # Merge two symbol→amount hashes and emit normalized asset list + def merge_earn_assets(flexible_totals, locked_totals) + all_symbols = (flexible_totals.keys + locked_totals.keys).uniq + all_symbols.filter_map do |symbol| + flex = flexible_totals[symbol] || BigDecimal("0") + lock = locked_totals[symbol] || BigDecimal("0") + total = flex + lock + next if total.zero? + + { symbol: symbol, free: flex.to_s("F"), locked: lock.to_s("F"), total: total.to_s("F") } + end + end +end diff --git a/app/models/binance_item/importer.rb b/app/models/binance_item/importer.rb new file mode 100644 index 000000000..7d499db70 --- /dev/null +++ b/app/models/binance_item/importer.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +# Orchestrates all Binance sub-importers and upserts a single combined BinanceAccount. +class BinanceItem::Importer + attr_reader :binance_item, :binance_provider + + def initialize(binance_item, binance_provider:) + @binance_item = binance_item + @binance_provider = binance_provider + end + + def import + Rails.logger.info "BinanceItem::Importer #{binance_item.id} - starting import" + + spot_result = BinanceItem::SpotImporter.new(binance_item, provider: binance_provider).import + margin_result = BinanceItem::MarginImporter.new(binance_item, provider: binance_provider).import + earn_result = BinanceItem::EarnImporter.new(binance_item, provider: binance_provider).import + + all_assets = tagged_assets(spot_result) + tagged_assets(margin_result) + tagged_assets(earn_result) + + return { success: true, assets_imported: 0, total_usd: 0 } if all_assets.empty? + + total_usd = calculate_total_usd(all_assets) + + upsert_binance_account( + all_assets: all_assets, + total_usd: total_usd, + spot_raw: spot_result[:raw], + margin_raw: margin_result[:raw], + earn_raw: earn_result[:raw] + ) + + binance_item.upsert_binance_snapshot!({ + "spot" => spot_result[:raw], + "margin" => margin_result[:raw], + "earn" => earn_result[:raw], + "imported_at" => Time.current.iso8601 + }) + + Rails.logger.info "BinanceItem::Importer #{binance_item.id} - imported #{all_assets.size} assets, total_usd=#{total_usd}" + + { success: true, assets_imported: all_assets.size, total_usd: total_usd } + end + + private + + def tagged_assets(result) + result[:assets].map { |a| a.merge(source: result[:source]) } + end + + def calculate_total_usd(assets) + assets.sum do |asset| + quantity = asset[:total].to_d + next 0 if quantity.zero? + + price = price_for(asset[:symbol]) + quantity * price + end.round(2) + end + + def price_for(symbol) + return 1.0 if BinanceAccount::STABLECOINS.include?(symbol) + + price = binance_provider.get_spot_price("#{symbol}USDT") + price.to_d + rescue => e + Rails.logger.warn "BinanceItem::Importer - could not get price for #{symbol}: #{e.message}" + 0 + end + + def upsert_binance_account(all_assets:, total_usd:, spot_raw:, margin_raw:, earn_raw:) + ba = binance_item.binance_accounts.find_or_initialize_by(account_type: "combined") + + ba.assign_attributes( + name: binance_item.institution_name.presence || "Binance", + currency: "USD", + current_balance: total_usd, + institution_metadata: build_institution_metadata(all_assets), + raw_payload: { + "spot" => spot_raw, + "margin" => margin_raw, + "earn" => earn_raw, + "assets" => all_assets.map(&:stringify_keys), + "fetched_at" => Time.current.iso8601 + } + ) + + ba.save! + ba + end + + def build_institution_metadata(all_assets) + %w[spot margin earn].each_with_object({}) do |source, hash| + source_assets = all_assets.select { |a| a[:source] == source } + hash[source] = { + "asset_count" => source_assets.size, + "assets" => source_assets.map { |a| a[:symbol] } + } + end + end +end diff --git a/app/models/binance_item/margin_importer.rb b/app/models/binance_item/margin_importer.rb new file mode 100644 index 000000000..3079a6baa --- /dev/null +++ b/app/models/binance_item/margin_importer.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# Fetches Binance Margin account balances. +# Returns normalized asset list with source tag "margin". +class BinanceItem::MarginImporter + attr_reader :binance_item, :provider + + def initialize(binance_item, provider:) + @binance_item = binance_item + @provider = provider + end + + def import + raw = provider.get_margin_account + assets = parse_assets(raw["userAssets"] || []) + { assets: assets, raw: raw, source: "margin" } + rescue => e + Rails.logger.error "BinanceItem::MarginImporter #{binance_item.id} - #{e.message}" + { assets: [], raw: nil, source: "margin", error: e.message } + end + + private + + def parse_assets(user_assets) + user_assets.filter_map do |a| + # Use netAsset (assets minus borrowed) as the meaningful balance + net = a["netAsset"].to_d + free = a["free"].to_d + locked = a["locked"].to_d + total = net + next if total.zero? + + { symbol: a["asset"], free: free.to_s("F"), locked: locked.to_s("F"), total: total.to_s("F"), net: net.to_s("F") } + end + end +end diff --git a/app/models/binance_item/provided.rb b/app/models/binance_item/provided.rb new file mode 100644 index 000000000..7a5398404 --- /dev/null +++ b/app/models/binance_item/provided.rb @@ -0,0 +1,9 @@ +module BinanceItem::Provided + extend ActiveSupport::Concern + + def binance_provider + return nil unless credentials_configured? + + Provider::Binance.new(api_key: api_key, api_secret: api_secret) + end +end diff --git a/app/models/binance_item/spot_importer.rb b/app/models/binance_item/spot_importer.rb new file mode 100644 index 000000000..eb04ab676 --- /dev/null +++ b/app/models/binance_item/spot_importer.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +# Fetches Binance Spot wallet balances. +# Returns normalized asset list with source tag "spot". +class BinanceItem::SpotImporter + attr_reader :binance_item, :provider + + def initialize(binance_item, provider:) + @binance_item = binance_item + @provider = provider + end + + # @return [Hash] { assets: [...], raw: , source: "spot" } + def import + raw = provider.get_spot_account + assets = parse_assets(raw["balances"] || []) + { assets: assets, raw: raw, source: "spot" } + rescue => e + Rails.logger.error "BinanceItem::SpotImporter #{binance_item.id} - #{e.message}" + { assets: [], raw: nil, source: "spot", error: e.message } + end + + private + + def parse_assets(balances) + balances.filter_map do |b| + free = b["free"].to_d + locked = b["locked"].to_d + total = free + locked + next if total.zero? + + { symbol: b["asset"], free: free.to_s("F"), locked: locked.to_s("F"), total: total.to_s("F") } + end + end +end diff --git a/app/models/binance_item/sync_complete_event.rb b/app/models/binance_item/sync_complete_event.rb new file mode 100644 index 000000000..d76449a91 --- /dev/null +++ b/app/models/binance_item/sync_complete_event.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +# Broadcasts Turbo Stream updates when a Binance sync completes. +# Updates account views and notifies the family of sync completion. +class BinanceItem::SyncCompleteEvent + attr_reader :binance_item + + # @param binance_item [BinanceItem] The item that completed syncing + def initialize(binance_item) + @binance_item = binance_item + end + + # Broadcasts sync completion to update UI components. + def broadcast + # Update UI with latest account data + binance_item.accounts.each do |account| + account.broadcast_sync_complete + end + + # Update the Binance item view + binance_item.broadcast_replace_to( + binance_item.family, + target: "binance_item_#{binance_item.id}", + partial: "binance_items/binance_item", + locals: { binance_item: binance_item } + ) + + # Let family handle sync notifications + binance_item.family.broadcast_sync_complete + end +end diff --git a/app/models/binance_item/syncer.rb b/app/models/binance_item/syncer.rb new file mode 100644 index 000000000..e98b2fe4d --- /dev/null +++ b/app/models/binance_item/syncer.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +# Orchestrates the sync process for a Binance connection. +class BinanceItem::Syncer + include SyncStats::Collector + + attr_reader :binance_item + + def initialize(binance_item) + @binance_item = binance_item + end + + def perform_sync(sync) + # Phase 1: Check credentials + sync.update!(status_text: I18n.t("binance_item.syncer.checking_credentials")) if sync.respond_to?(:status_text) + unless binance_item.credentials_configured? + binance_item.update!(status: :requires_update) + mark_failed(sync, I18n.t("binance_item.syncer.credentials_invalid")) + return + end + + begin + # Phase 2: Import from Binance APIs + sync.update!(status_text: I18n.t("binance_item.syncer.importing_accounts")) if sync.respond_to?(:status_text) + binance_item.import_latest_binance_data + + # Clear error status if import succeeds + binance_item.update!(status: :good) if binance_item.status == "requires_update" + + # Phase 3: Check setup status + sync.update!(status_text: I18n.t("binance_item.syncer.checking_configuration")) if sync.respond_to?(:status_text) + collect_setup_stats(sync, provider_accounts: binance_item.binance_accounts.to_a) + + unlinked = binance_item.binance_accounts.left_joins(:account_provider).where(account_providers: { id: nil }) + linked = binance_item.binance_accounts.joins(:account_provider).joins(:account).merge(Account.visible) + + if unlinked.any? + binance_item.update!(pending_account_setup: true) + sync.update!(status_text: I18n.t("binance_item.syncer.accounts_need_setup", count: unlinked.count)) if sync.respond_to?(:status_text) + else + binance_item.update!(pending_account_setup: false) + end + + # Phase 4: Process linked accounts + if linked.any? + sync.update!(status_text: I18n.t("binance_item.syncer.processing_accounts")) if sync.respond_to?(:status_text) + binance_item.process_accounts + + # Phase 5: Schedule balance calculations + sync.update!(status_text: I18n.t("binance_item.syncer.calculating_balances")) if sync.respond_to?(:status_text) + binance_item.schedule_account_syncs( + parent_sync: sync, + window_start_date: sync.window_start_date, + window_end_date: sync.window_end_date + ) + + account_ids = linked.map { |ba| ba.current_account&.id }.compact + if account_ids.any? + collect_transaction_stats(sync, account_ids: account_ids, source: "binance") + collect_trades_stats(sync, account_ids: account_ids, source: "binance") + end + end + rescue StandardError => e + Rails.logger.error "BinanceItem::Syncer - unexpected error during sync: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}" + mark_failed(sync, e.message) + raise + end + end + + def perform_post_sync + # no-op + end + + private + + def mark_failed(sync, error_message) + if sync.respond_to?(:status) && sync.status.to_s == "completed" + Rails.logger.warn("BinanceItem::Syncer#mark_failed called after completion: #{error_message}") + return + end + + sync.start! if sync.respond_to?(:may_start?) && sync.may_start? + + if sync.respond_to?(:may_fail?) && sync.may_fail? + sync.fail! + elsif sync.respond_to?(:status) + sync.update!(status: :failed) + end + + sync.update!(error: error_message) if sync.respond_to?(:error) + sync.update!(status_text: error_message) if sync.respond_to?(:status_text) + end +end diff --git a/app/models/binance_item/unlinking.rb b/app/models/binance_item/unlinking.rb new file mode 100644 index 000000000..71e855fef --- /dev/null +++ b/app/models/binance_item/unlinking.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module BinanceItem::Unlinking + extend ActiveSupport::Concern + + def unlink_all!(dry_run: false) + results = [] + + binance_accounts.find_each do |provider_account| + links = AccountProvider.where(provider_type: BinanceAccount.name, provider_id: provider_account.id).to_a + link_ids = links.map(&:id) + result = { + provider_account_id: provider_account.id, + name: provider_account.name, + provider_link_ids: link_ids + } + results << result + + next if dry_run + + begin + ActiveRecord::Base.transaction do + if link_ids.any? + Holding.where(account_provider_id: link_ids).update_all(account_provider_id: nil) + end + links.each(&:destroy!) + end + rescue StandardError => e + Rails.logger.warn("BinanceItem Unlinker: failed to unlink ##{provider_account.id}: #{e.class} - #{e.message}") + result[:error] = e.message + end + end + + results + end +end diff --git a/app/models/budget.rb b/app/models/budget.rb index c345801fc..64fcdb73d 100644 --- a/app/models/budget.rb +++ b/app/models/budget.rb @@ -3,6 +3,8 @@ class Budget < ApplicationRecord PARAM_DATE_FORMAT = "%b-%Y" + attr_accessor :current_user + belongs_to :family has_many :budget_categories, -> { includes(:category) }, dependent: :destroy @@ -38,7 +40,7 @@ class Budget < ApplicationRecord end end - def find_or_bootstrap(family, start_date:) + def find_or_bootstrap(family, start_date:, user: nil) return nil unless budget_date_valid?(start_date, family: family) Budget.transaction do @@ -58,6 +60,7 @@ class Budget < ApplicationRecord b.currency = family.currency end + budget.current_user = user budget.sync_budget_categories budget @@ -81,7 +84,7 @@ class Budget < ApplicationRecord end def sync_budget_categories - current_category_ids = family.categories.expenses.pluck(:id).to_set + current_category_ids = family.categories.pluck(:id).to_set existing_budget_category_ids = budget_categories.pluck(:category_id).to_set categories_to_add = current_category_ids - existing_budget_category_ids categories_to_remove = existing_budget_category_ids - current_category_ids @@ -107,7 +110,11 @@ class Budget < ApplicationRecord end def transactions - family.transactions.visible.in_period(period) + scope = family.transactions.visible.in_period(period) + if current_user + scope = scope.joins(:entry).where(entries: { account_id: family.accounts.accessible_by(current_user).select(:id) }) + end + scope end def name @@ -126,12 +133,42 @@ class Budget < ApplicationRecord budgeted_spending.present? end + def most_recent_initialized_budget + family.budgets + .includes(:budget_categories) + .where("start_date < ?", start_date) + .where.not(budgeted_spending: nil) + .order(start_date: :desc) + .first + end + + def copy_from!(source_budget) + raise ArgumentError, "source budget must belong to the same family" unless source_budget.family_id == family_id + raise ArgumentError, "source budget must precede target budget" unless source_budget.start_date < start_date + + Budget.transaction do + update!( + budgeted_spending: source_budget.budgeted_spending, + expected_income: source_budget.expected_income + ) + + target_by_category = budget_categories.index_by(&:category_id) + + source_budget.budget_categories.each do |source_bc| + target_bc = target_by_category[source_bc.category_id] + next unless target_bc + + target_bc.update!(budgeted_spending: source_bc.budgeted_spending) + end + end + end + def income_category_totals - income_totals.category_totals.reject { |ct| ct.category.subcategory? || ct.total.zero? }.sort_by(&:weight).reverse + net_totals.net_income_categories.reject { |ct| ct.total.zero? }.sort_by(&:weight).reverse end def expense_category_totals - expense_totals.category_totals.reject { |ct| ct.category.subcategory? || ct.total.zero? }.sort_by(&:weight).reverse + net_totals.net_expense_categories.reject { |ct| ct.total.zero? }.sort_by(&:weight).reverse end def current? @@ -184,13 +221,13 @@ class Budget < ApplicationRecord end def actual_spending - [ expense_totals.total - refunds_in_expense_categories, 0 ].max + net_totals.total_net_expense end def budget_category_actual_spending(budget_category) - cat_id = budget_category.category_id - expense = expense_totals_by_category[cat_id]&.total || 0 - refund = income_totals_by_category[cat_id]&.total || 0 + key = budget_category.category_id || stable_synthetic_key(budget_category.category) + expense = expense_totals_by_category[key]&.total || 0 + refund = income_totals_by_category[key]&.total || 0 [ expense - refund, 0 ].max end @@ -267,16 +304,12 @@ class Budget < ApplicationRecord end private - def refunds_in_expense_categories - expense_category_ids = budget_categories.map(&:category_id).to_set - income_totals.category_totals - .reject { |ct| ct.category.subcategory? } - .select { |ct| expense_category_ids.include?(ct.category.id) || ct.category.uncategorized? } - .sum(&:total) + def income_statement + @income_statement ||= family.income_statement(user: current_user) end - def income_statement - @income_statement ||= family.income_statement + def net_totals + @net_totals ||= income_statement.net_category_totals(period: period) end def expense_totals @@ -284,14 +317,22 @@ class Budget < ApplicationRecord end def income_totals - @income_totals ||= family.income_statement.income_totals(period: period) + @income_totals ||= income_statement.income_totals(period: period) end def expense_totals_by_category - @expense_totals_by_category ||= expense_totals.category_totals.index_by { |ct| ct.category.id } + @expense_totals_by_category ||= expense_totals.category_totals.index_by { |ct| ct.category.id || stable_synthetic_key(ct.category) } end def income_totals_by_category - @income_totals_by_category ||= income_totals.category_totals.index_by { |ct| ct.category.id } + @income_totals_by_category ||= income_totals.category_totals.index_by { |ct| ct.category.id || stable_synthetic_key(ct.category) } + end + + def stable_synthetic_key(category) + if category.uncategorized? + :uncategorized + elsif category.other_investments? + :other_investments + end end end diff --git a/app/models/budget_category.rb b/app/models/budget_category.rb index 27d999703..764e3d744 100644 --- a/app/models/budget_category.rb +++ b/app/models/budget_category.rb @@ -53,6 +53,17 @@ class BudgetCategory < ApplicationRecord budget.budget_category_actual_spending(self) end + def update_budgeted_spending!(new_budgeted_spending) + self.class.transaction do + lock! + + previous_budgeted_spending = budgeted_spending || 0 + update!(budgeted_spending: new_budgeted_spending) + + sync_parent_budgeted_spending!(previous_budgeted_spending:) if subcategory? + end + end + def avg_monthly_expense budget.category_avg_monthly_expense(category) end @@ -192,26 +203,36 @@ class BudgetCategory < ApplicationRecord budget.budget_categories.select { |bc| bc.category.parent_id == category.parent_id && bc.id != id } end - def max_allocation - return nil unless subcategory? - - parent_budget_cat = budget.budget_categories.find { |bc| bc.category.id == category.parent_id } - return nil unless parent_budget_cat - - parent_budget = parent_budget_cat[:budgeted_spending] || 0 - - # Sum budgets of siblings that have individual limits (excluding those that inherit) - siblings_with_limits = siblings.reject(&:inherits_parent_budget?) - siblings_budget = siblings_with_limits.sum { |s| s[:budgeted_spending] || 0 } - - [ parent_budget - siblings_budget, 0 ].max - end - def subcategories return BudgetCategory.none unless category.parent_id.nil? + return BudgetCategory.none if category.id.nil? budget.budget_categories .joins(:category) .where(categories: { parent_id: category.id }) end + + private + def sync_parent_budgeted_spending!(previous_budgeted_spending:) + parent_budget_category = budget.budget_categories.where(category_id: category.parent_id).lock.first + return unless parent_budget_category + + sibling_budgeted_spending = budget.budget_categories + .joins(:category) + .where(categories: { parent_id: category.parent_id }) + .where.not(id: id) + .sum(:budgeted_spending) + + # Preserve positive parent reserve—the extra budget assigned directly to the parent + # beyond the sum of its subcategories—but do not carry forward a negative reserve + # that would leave the parent below its subcategory total. + parent_budget_reserve = [ + (parent_budget_category.budgeted_spending || 0) - sibling_budgeted_spending - previous_budgeted_spending, + 0 + ].max + + parent_budget_category.update!( + budgeted_spending: sibling_budgeted_spending + (budgeted_spending || 0) + parent_budget_reserve + ) + end end diff --git a/app/models/category.rb b/app/models/category.rb index 02acee0f6..39a740f7d 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -9,10 +9,10 @@ class Category < ApplicationRecord belongs_to :parent, class_name: "Category", optional: true validates :name, :color, :lucide_icon, :family, presence: true + validates :color, format: { with: /\A#[0-9A-Fa-f]{6}\z/ } validates :name, uniqueness: { scope: :family_id } validate :category_level_limit - validate :nested_category_matches_parent_classification before_save :inherit_color_from_parent @@ -24,8 +24,9 @@ class Category < ApplicationRecord .order(:name) } scope :roots, -> { where(parent_id: nil) } - scope :incomes, -> { where(classification: "income") } - scope :expenses, -> { where(classification: "expense") } + # Legacy scopes - classification removed; these now return all categories + scope :incomes, -> { all } + scope :expenses, -> { all } COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a] @@ -35,6 +36,55 @@ class Category < ApplicationRecord PAYMENT_COLOR = "#db5a54" TRADE_COLOR = "#e99537" + ICON_KEYWORDS = { + /income|salary|paycheck|wage|earning/ => "circle-dollar-sign", + /groceries|grocery|supermarket/ => "shopping-bag", + /food|dining|restaurant|meal|lunch|dinner|breakfast/ => "utensils", + /coffee|cafe|café/ => "coffee", + /shopping|retail/ => "shopping-cart", + /transport|transit|commute|subway|metro/ => "bus", + /parking/ => "circle-parking", + /car|auto|vehicle/ => "car", + /gas|fuel|petrol/ => "fuel", + /flight|airline/ => "plane", + /travel|trip|vacation|holiday/ => "plane", + /hotel|lodging|accommodation/ => "hotel", + /movie|cinema|film|theater|theatre/ => "film", + /music|concert/ => "music", + /game|gaming/ => "gamepad-2", + /entertainment|leisure/ => "drama", + /sport|fitness|gym|workout|exercise/ => "dumbbell", + /pharmacy|drug|medicine|pill|medication|dental|dentist/ => "pill", + /health|medical|clinic|doctor|physician/ => "stethoscope", + /personal care|beauty|salon|spa|hair/ => "scissors", + /mortgage|rent/ => "home", + /home|house|apartment|housing/ => "home", + /improvement|renovation|remodel/ => "hammer", + /repair|maintenance/ => "wrench", + /electric|power|energy/ => "zap", + /water|sewage/ => "waves", + /internet|cable|broadband|subscription|streaming/ => "wifi", + /utilities|utility/ => "lightbulb", + /phone|telephone/ => "phone", + /mobile|cell/ => "smartphone", + /insurance/ => "shield", + /gift|present/ => "gift", + /donat|charity|nonprofit/ => "hand-helping", + /tax|irs|revenue/ => "landmark", + /loan|debt|credit card/ => "credit-card", + /service|professional/ => "briefcase", + /fee|charge/ => "receipt", + /bank|banking/ => "landmark", + /saving/ => "piggy-bank", + /invest|stock|fund|portfolio/ => "trending-up", + /pet|dog|cat|animal|vet/ => "paw-print", + /education|school|university|college|tuition/ => "graduation-cap", + /book|reading|library/ => "book", + /child|kid|baby|infant|daycare/ => "baby", + /cloth|apparel|fashion|wear/ => "shirt", + /ticket/ => "ticket" + }.freeze + # Category name keys for i18n UNCATEGORIZED_NAME_KEY = "models.category.uncategorized" OTHER_INVESTMENTS_NAME_KEY = "models.category.other_investments" @@ -58,6 +108,16 @@ class Category < ApplicationRecord end class << self + def suggested_icon(name) + name_down = name.to_s.downcase + + ICON_KEYWORDS.each do |pattern, icon| + return icon if name_down.match?(pattern) + end + + "shapes" + end + def icon_codes %w[ ambulance apple award baby badge-dollar-sign banknote barcode bar-chart-3 bath @@ -79,10 +139,9 @@ class Category < ApplicationRecord end def bootstrap! - default_categories.each do |name, color, icon, classification| + default_categories.each do |name, color, icon| find_or_create_by!(name: name) do |category| category.color = color - category.classification = classification category.lucide_icon = icon end end @@ -138,28 +197,28 @@ class Category < ApplicationRecord private def default_categories [ - [ "Income", "#22c55e", "circle-dollar-sign", "income" ], - [ "Food & Drink", "#f97316", "utensils", "expense" ], - [ "Groceries", "#407706", "shopping-bag", "expense" ], - [ "Shopping", "#3b82f6", "shopping-cart", "expense" ], - [ "Transportation", "#0ea5e9", "bus", "expense" ], - [ "Travel", "#2563eb", "plane", "expense" ], - [ "Entertainment", "#a855f7", "drama", "expense" ], - [ "Healthcare", "#4da568", "pill", "expense" ], - [ "Personal Care", "#14b8a6", "scissors", "expense" ], - [ "Home Improvement", "#d97706", "hammer", "expense" ], - [ "Mortgage / Rent", "#b45309", "home", "expense" ], - [ "Utilities", "#eab308", "lightbulb", "expense" ], - [ "Subscriptions", "#6366f1", "wifi", "expense" ], - [ "Insurance", "#0284c7", "shield", "expense" ], - [ "Sports & Fitness", "#10b981", "dumbbell", "expense" ], - [ "Gifts & Donations", "#61c9ea", "hand-helping", "expense" ], - [ "Taxes", "#dc2626", "landmark", "expense" ], - [ "Loan Payments", "#e11d48", "credit-card", "expense" ], - [ "Services", "#7c3aed", "briefcase", "expense" ], - [ "Fees", "#6b7280", "receipt", "expense" ], - [ "Savings & Investments", "#059669", "piggy-bank", "expense" ], - [ investment_contributions_name, "#0d9488", "trending-up", "expense" ] + [ "Income", "#22c55e", "circle-dollar-sign" ], + [ "Food & Drink", "#f97316", "utensils" ], + [ "Groceries", "#407706", "shopping-bag" ], + [ "Shopping", "#3b82f6", "shopping-cart" ], + [ "Transportation", "#0ea5e9", "bus" ], + [ "Travel", "#2563eb", "plane" ], + [ "Entertainment", "#a855f7", "drama" ], + [ "Healthcare", "#4da568", "pill" ], + [ "Personal Care", "#14b8a6", "scissors" ], + [ "Home Improvement", "#d97706", "hammer" ], + [ "Mortgage / Rent", "#b45309", "home" ], + [ "Utilities", "#eab308", "lightbulb" ], + [ "Subscriptions", "#6366f1", "wifi" ], + [ "Insurance", "#0284c7", "shield" ], + [ "Sports & Fitness", "#10b981", "dumbbell" ], + [ "Gifts & Donations", "#61c9ea", "hand-helping" ], + [ "Taxes", "#dc2626", "landmark" ], + [ "Loan Payments", "#e11d48", "credit-card" ], + [ "Services", "#7c3aed", "briefcase" ], + [ "Fees", "#6b7280", "receipt" ], + [ "Savings & Investments", "#059669", "piggy-bank" ], + [ investment_contributions_name, "#0d9488", "trending-up" ] ] end end @@ -211,12 +270,6 @@ class Category < ApplicationRecord end end - def nested_category_matches_parent_classification - if subcategory? && parent.classification != classification - errors.add(:parent, "must have the same classification as its parent") - end - end - def monetizable_currency family.currency end diff --git a/app/models/category_import.rb b/app/models/category_import.rb index 59f8095f4..f58a50b70 100644 --- a/app/models/category_import.rb +++ b/app/models/category_import.rb @@ -5,7 +5,6 @@ class CategoryImport < Import category_name = row.name.to_s.strip category = family.categories.find_or_initialize_by(name: category_name) category.color = row.category_color.presence || category.color || Category::UNCATEGORIZED_COLOR - category.classification = row.category_classification.presence || category.classification || "expense" category.lucide_icon = row.category_icon.presence || category.lucide_icon || "shapes" category.parent = nil category.save! @@ -30,7 +29,7 @@ class CategoryImport < Import end def column_keys - %i[name category_color category_parent category_classification category_icon] + %i[name category_color category_parent category_icon] end def required_column_keys @@ -47,10 +46,10 @@ class CategoryImport < Import def csv_template template = <<-CSV - name*,color,parent_category,classification,lucide_icon - Food & Drink,#f97316,,expense,carrot - Groceries,#407706,Food & Drink,expense,shopping-basket - Salary,#22c55e,,income,briefcase + name*,color,parent_category,lucide_icon + Food & Drink,#f97316,,carrot + Groceries,#407706,Food & Drink,shopping-basket + Salary,#22c55e,,briefcase CSV CSV.parse(template, headers: true) @@ -64,7 +63,6 @@ class CategoryImport < Import name_header = header_for("name") color_header = header_for("color") parent_header = header_for("parent_category", "parent category") - classification_header = header_for("classification") icon_header = header_for("lucide_icon", "lucide icon", "icon") csv_rows.each do |row| @@ -72,7 +70,6 @@ class CategoryImport < Import name: row[name_header].to_s.strip, category_color: row[color_header].to_s.strip, category_parent: row[parent_header].to_s.strip, - category_classification: row[classification_header].to_s.strip, category_icon: row[icon_header].to_s.strip, currency: default_currency ) @@ -112,7 +109,6 @@ class CategoryImport < Import family.categories.find_or_create_by!(name: trimmed_name) do |placeholder| placeholder.color = Category::UNCATEGORIZED_COLOR - placeholder.classification = "expense" placeholder.lucide_icon = "shapes" end end diff --git a/app/models/chat.rb b/app/models/chat.rb index 7367c9b12..d47dcccac 100644 --- a/app/models/chat.rb +++ b/app/models/chat.rb @@ -75,10 +75,6 @@ class Chat < ApplicationRecord end def conversation_messages - if debug_mode? - messages - else - messages.where(type: [ "UserMessage", "AssistantMessage" ]) - end + messages.where(type: [ "UserMessage", "AssistantMessage" ]) end end diff --git a/app/models/coinbase_account.rb b/app/models/coinbase_account.rb index c66423feb..56438a5d5 100644 --- a/app/models/coinbase_account.rb +++ b/app/models/coinbase_account.rb @@ -15,6 +15,7 @@ class CoinbaseAccount < ApplicationRecord has_one :linked_account, through: :account_provider, source: :account validates :name, :currency, presence: true + validates :account_id, uniqueness: { scope: :coinbase_item_id, allow_nil: true } # Helper to get account using account_providers system def current_account diff --git a/app/models/coinbase_item.rb b/app/models/coinbase_item.rb index 6641958e6..0adf2fe11 100644 --- a/app/models/coinbase_item.rb +++ b/app/models/coinbase_item.rb @@ -1,17 +1,9 @@ class CoinbaseItem < ApplicationRecord include Syncable, Provided, Unlinking + include Encryptable enum :status, { good: "good", requires_update: "requires_update" }, default: :good - # Helper to detect if ActiveRecord Encryption is configured for this app - def self.encryption_ready? - creds_ready = Rails.application.credentials.active_record_encryption.present? - env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? && - ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? && - ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present? - creds_ready || env_ready - end - # Encrypt sensitive credentials if ActiveRecord encryption is configured # api_key uses deterministic encryption for querying, api_secret uses standard encryption if encryption_ready? diff --git a/app/models/coinstats_account.rb b/app/models/coinstats_account.rb index 86231321f..4868df014 100644 --- a/app/models/coinstats_account.rb +++ b/app/models/coinstats_account.rb @@ -1,7 +1,10 @@ -# Represents a single crypto token/coin within a CoinStats wallet. -# Each wallet address may have multiple CoinstatsAccounts (one per token). +# Represents a CoinStats-backed synced account. +# This may be a wallet-scoped asset row or a consolidated exchange portfolio. class CoinstatsAccount < ApplicationRecord include CurrencyNormalizable, Encryptable + include CoinstatsAccount::ValueHelpers + include CoinstatsAccount::SourceClassification + include CoinstatsAccount::BalanceInference # Encrypt raw payloads if ActiveRecord encryption is configured if encryption_ready? @@ -24,13 +27,11 @@ class CoinstatsAccount < ApplicationRecord # Updates account with latest balance data from CoinStats API. # @param account_snapshot [Hash] Normalized balance data from API def upsert_coinstats_snapshot!(account_snapshot) - # Convert to symbol keys or handle both string and symbol keys snapshot = account_snapshot.with_indifferent_access - # Build attributes to update attrs = { - current_balance: snapshot[:balance] || snapshot[:current_balance], - currency: parse_currency(snapshot[:currency]) || "USD", + current_balance: snapshot[:balance] || snapshot[:current_balance] || inferred_current_balance(snapshot), + currency: inferred_currency(snapshot) || parse_currency(snapshot[:currency]) || "USD", name: snapshot[:name], account_status: snapshot[:status], provider: snapshot[:provider], @@ -40,10 +41,7 @@ class CoinstatsAccount < ApplicationRecord raw_payload: account_snapshot } - # Only set account_id if provided and not already set (preserves ID from initial creation) - if snapshot[:id].present? && account_id.blank? - attrs[:account_id] = snapshot[:id].to_s - end + attrs[:account_id] = snapshot[:id].to_s if snapshot[:id].present? && account_id.blank? update!(attrs) end @@ -51,8 +49,6 @@ class CoinstatsAccount < ApplicationRecord # Stores transaction data from CoinStats API for later processing. # @param transactions_snapshot [Hash, Array] Raw transactions response or array def upsert_coinstats_transactions_snapshot!(transactions_snapshot) - # CoinStats API returns: { meta: { page, limit }, result: [...] } - # Extract just the result array for storage, or use directly if already an array transactions_array = if transactions_snapshot.is_a?(Hash) snapshot = transactions_snapshot.with_indifferent_access snapshot[:result] || [] @@ -62,16 +58,7 @@ class CoinstatsAccount < ApplicationRecord [] end - assign_attributes( - raw_transactions_payload: transactions_array - ) - + assign_attributes(raw_transactions_payload: transactions_array) save! end - - private - - def log_invalid_currency(currency_value) - Rails.logger.warn("Invalid currency code '#{currency_value}' for CoinstatsAccount #{id}, defaulting to USD") - end end diff --git a/app/models/coinstats_account/balance_inference.rb b/app/models/coinstats_account/balance_inference.rb new file mode 100644 index 000000000..ab9ccf523 --- /dev/null +++ b/app/models/coinstats_account/balance_inference.rb @@ -0,0 +1,138 @@ +module CoinstatsAccount::BalanceInference + extend ActiveSupport::Concern + + def inferred_currency(payload = raw_payload) + payload = payload.to_h.with_indifferent_access + + if exchange_portfolio_source_for?(payload) + preferred_exchange_currency + elsif exchange_source_for?(payload) + if fiat_asset?(payload) + parse_currency(asset_metadata(payload)[:symbol]) || + parse_currency(payload[:currency]) || + family_currency || + "USD" + else + preferred_exchange_currency + end + elsif fiat_asset?(payload) + parse_currency(asset_metadata(payload)[:symbol]) || parse_currency(payload[:currency]) || "USD" + else + parse_currency(payload[:currency]) || "USD" + end + end + + def inferred_current_balance(payload = raw_payload) + payload = payload.to_h.with_indifferent_access + + if exchange_portfolio_source_for?(payload) + portfolio_total_value(payload) + elsif fiat_asset?(payload) + asset_quantity(payload).abs + elsif exchange_source_for?(payload) + asset_quantity(payload).abs * asset_price(payload) + else + explicit_balance = payload[:balance] || payload[:current_balance] + return parse_decimal(explicit_balance) if explicit_balance.present? + + asset_quantity(payload).abs * asset_price(payload) + end + end + + def inferred_cash_balance + return portfolio_cash_value if exchange_portfolio_account? + + fiat_asset? ? inferred_current_balance : 0.to_d + end + + def asset_symbol(payload = raw_payload) + asset_metadata(payload)[:symbol].presence || account_id.to_s.upcase + end + + def asset_name(payload = raw_payload) + asset_metadata(payload)[:name].presence || name + end + + def asset_quantity(payload = raw_payload) + payload = payload.to_h.with_indifferent_access + raw_quantity = payload[:count] || payload[:amount] || payload[:balance] || payload[:current_balance] + parse_decimal(raw_quantity) + end + + def asset_price(payload = raw_payload, currency: inferred_currency(payload)) + payload = payload.to_h.with_indifferent_access + price_data = payload[:price] + target_currency = parse_currency(currency) || currency || "USD" + + raw_price = + if price_data.is_a?(Hash) + prices = price_data.with_indifferent_access + prices[target_currency] || + prices[target_currency.to_s] || + converted_usd_amount(prices[:USD] || prices["USD"], target_currency) + else + price_data || payload[:priceUsd] + end + + parse_decimal(raw_price) + end + + def average_buy_price(payload = raw_payload, currency: inferred_currency(payload)) + payload = payload.to_h.with_indifferent_access + average_buy = payload[:averageBuy] + return nil if average_buy.blank? + + average_buy_hash = average_buy.to_h.with_indifferent_access + nested_all_time = average_buy_hash[:allTime].to_h.with_indifferent_access + target_currency = parse_currency(currency) || currency || "USD" + + raw_cost_basis = + average_buy_hash[target_currency] || + average_buy_hash[target_currency.to_s] || + nested_all_time[target_currency] || + nested_all_time[target_currency.to_s] || + converted_usd_amount( + average_buy_hash[:USD] || average_buy_hash["USD"] || + nested_all_time[:USD] || nested_all_time["USD"], + target_currency + ) + return nil if raw_cost_basis.blank? + + parse_decimal(raw_cost_basis) + end + + def portfolio_coins(payload = raw_payload) + payload = payload.to_h.with_indifferent_access + Array(payload[:coins]).map { |coin| coin.with_indifferent_access } + end + + def portfolio_fiat_coins(payload = raw_payload) + portfolio_coins(payload).select { |coin| fiat_asset?(coin) } + end + + def portfolio_non_fiat_coins(payload = raw_payload) + portfolio_coins(payload).reject { |coin| fiat_asset?(coin) } + end + + def portfolio_total_value(payload = raw_payload, currency: inferred_currency(payload)) + portfolio_coins(payload).sum { |coin| current_value_for_coin(coin, currency: currency) } + end + + def portfolio_cash_value(payload = raw_payload, currency: inferred_currency(payload)) + portfolio_fiat_coins(payload).sum { |coin| current_value_for_coin(coin, currency: currency) } + end + + def current_value_for_coin(coin_payload, currency: inferred_currency(coin_payload)) + coin_payload = coin_payload.to_h.with_indifferent_access + + explicit_value = coin_payload[:currentValue] || coin_payload[:current_value] || coin_payload[:totalWorth] + if explicit_value.present? + return extract_currency_amount(explicit_value, currency) if explicit_value.is_a?(Hash) + return exchange_scalar_value(explicit_value, coin_payload, currency: currency) if exchange_value_payload?(coin_payload) + + return parse_decimal(explicit_value) + end + + asset_quantity(coin_payload).abs * asset_price(coin_payload, currency: currency) + end +end diff --git a/app/models/coinstats_account/holdings_processor.rb b/app/models/coinstats_account/holdings_processor.rb new file mode 100644 index 000000000..c5e281765 --- /dev/null +++ b/app/models/coinstats_account/holdings_processor.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +class CoinstatsAccount::HoldingsProcessor + def initialize(coinstats_account) + @coinstats_account = coinstats_account + end + + def process + return unless account&.crypto? + + coinstats_account.exchange_portfolio_account? ? process_exchange_portfolio_holdings : process_single_asset_holding + end + + private + attr_reader :coinstats_account + + def account + coinstats_account.current_account + end + + def account_provider + coinstats_account.account_provider + end + + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + + def process_single_asset_holding + return if coinstats_account.fiat_asset? + + quantity = coinstats_account.asset_quantity + return if quantity.zero? + + security = resolve_security(coinstats_account.asset_symbol, coinstats_account.asset_name) + return unless security + + import_adapter.import_holding( + security: security, + quantity: quantity.abs, + amount: coinstats_account.inferred_current_balance, + currency: coinstats_account.inferred_currency, + date: holding_date, + price: coinstats_account.asset_price, + cost_basis: coinstats_account.average_buy_price, + external_id: single_asset_external_id, + account_provider_id: account_provider&.id, + source: "coinstats", + delete_future_holdings: false + ) + end + + def process_exchange_portfolio_holdings + return if account_provider.blank? + + active_coins = coinstats_account.portfolio_non_fiat_coins.reject { |coin| coinstats_account.asset_quantity(coin).zero? } + target_currency = coinstats_account.inferred_currency + cleanup_stale_holdings!(active_coins.map { |coin| portfolio_external_id(coin) }) + + active_coins.each do |coin| + security = resolve_security(asset_symbol(coin), asset_name(coin)) + next unless security + + quantity = coinstats_account.asset_quantity(coin).abs + next if quantity.zero? + + import_adapter.import_holding( + security: security, + quantity: quantity, + amount: coinstats_account.current_value_for_coin(coin, currency: target_currency), + currency: target_currency, + date: holding_date, + price: coinstats_account.asset_price(coin, currency: target_currency), + cost_basis: coinstats_account.average_buy_price(coin, currency: target_currency), + external_id: portfolio_external_id(coin), + account_provider_id: account_provider.id, + source: "coinstats", + delete_future_holdings: false + ) + end + end + + def cleanup_stale_holdings!(external_ids) + scope = account.holdings.where(account_provider_id: account_provider.id, date: holding_date) + + if external_ids.any? + scope.where.not(external_id: external_ids).delete_all + else + scope.delete_all + end + end + + def resolve_security(symbol, name) + return if symbol.blank? + + ticker = symbol.start_with?("CRYPTO:") ? symbol : "CRYPTO:#{symbol}" + security = Security::Resolver.new(ticker).resolve + return unless security + + updates = {} + updates[:name] = name if security.name.blank? && name.present? + updates[:offline] = true if security.respond_to?(:offline=) && security.offline != true + security.update!(updates) if updates.any? + security + rescue => e + Rails.logger.warn("CoinstatsAccount::HoldingsProcessor - Failed to resolve #{symbol}: #{e.class} - #{e.message}") + nil + end + + def asset_symbol(payload) + coinstats_account.asset_symbol(payload) + end + + def asset_name(payload) + coinstats_account.asset_name(payload) + end + + def single_asset_external_id + "coinstats_holding_#{coinstats_account.account_id}_#{holding_date}" + end + + def portfolio_external_id(coin_payload) + coin_payload = coin_payload.to_h.with_indifferent_access + identifier = coin_payload.dig(:coin, :identifier).presence || + coin_payload.dig(:coin, :symbol).presence || + coin_payload[:coinId].presence || + coin_payload[:symbol].presence + + "coinstats_holding_#{coinstats_account.account_id}_#{identifier}_#{holding_date}" + end + + def holding_date + Date.current + end +end diff --git a/app/models/coinstats_account/processor.rb b/app/models/coinstats_account/processor.rb index 70844d67f..c60716315 100644 --- a/app/models/coinstats_account/processor.rb +++ b/app/models/coinstats_account/processor.rb @@ -20,6 +20,13 @@ class CoinstatsAccount::Processor Rails.logger.info "CoinstatsAccount::Processor - Processing coinstats_account #{coinstats_account.id}" + begin + process_holdings + rescue StandardError => e + Rails.logger.error "CoinstatsAccount::Processor - Failed to process holdings for #{coinstats_account.id}: #{e.message}" + report_exception(e, "holdings") + end + begin process_account! rescue StandardError => e @@ -34,17 +41,24 @@ class CoinstatsAccount::Processor private + def process_holdings + CoinstatsAccount::HoldingsProcessor.new(coinstats_account).process + end + # Updates the linked Account with current balance from CoinStats. def process_account! account = coinstats_account.current_account balance = coinstats_account.current_balance || 0 currency = parse_currency(coinstats_account.currency) || account.currency || "USD" + cash_balance = coinstats_account.inferred_cash_balance account.update!( balance: balance, - cash_balance: balance, + cash_balance: cash_balance, currency: currency ) + + account.set_current_balance(balance) end # Delegates transaction processing to the specialized processor. diff --git a/app/models/coinstats_account/source_classification.rb b/app/models/coinstats_account/source_classification.rb new file mode 100644 index 000000000..97f867d4a --- /dev/null +++ b/app/models/coinstats_account/source_classification.rb @@ -0,0 +1,55 @@ +module CoinstatsAccount::SourceClassification + extend ActiveSupport::Concern + + def wallet_source? + payload = raw_payload.to_h.with_indifferent_access + payload[:source] == "wallet" || (payload[:address].present? && payload[:blockchain].present?) + end + + def exchange_source? + exchange_source_for?(raw_payload) + end + + def exchange_portfolio_account? + payload = raw_payload.to_h.with_indifferent_access + exchange_source_for?(payload) && ( + ActiveModel::Type::Boolean.new.cast(payload[:portfolio_account]) || + payload[:coins].is_a?(Array) + ) + end + + def legacy_exchange_asset_account? + exchange_source? && !exchange_portfolio_account? + end + + def fiat_asset?(payload = raw_payload) + payload = payload.to_h.with_indifferent_access + return false if exchange_portfolio_source_for?(payload) + + metadata = asset_metadata(payload) + + ActiveModel::Type::Boolean.new.cast(metadata[:isFiat]) || + ActiveModel::Type::Boolean.new.cast(payload[:isFiat]) || + fiat_identifier?(metadata[:identifier]) || + fiat_identifier?(payload[:coinId]) || + fiat_identifier?(account_id) + end + + def crypto_asset? + !fiat_asset? + end + + private + def exchange_source_for?(payload) + payload = payload.to_h.with_indifferent_access + payload[:source] == "exchange" || payload[:portfolio_id].present? + end + + def exchange_portfolio_source_for?(payload) + payload = payload.to_h.with_indifferent_access + exchange_source_for?(payload) && ( + ActiveModel::Type::Boolean.new.cast(payload[:portfolio_account]) || + payload[:coins].is_a?(Array) + ) + end +end diff --git a/app/models/coinstats_account/transactions/processor.rb b/app/models/coinstats_account/transactions/processor.rb index f5ee468f6..8cd603536 100644 --- a/app/models/coinstats_account/transactions/processor.rb +++ b/app/models/coinstats_account/transactions/processor.rb @@ -87,6 +87,7 @@ class CoinstatsAccount::Transactions::Processor # @return [Array] Transactions matching this account's token def filter_transactions_for_account(transactions) return [] unless transactions.present? + return transactions if coinstats_account.exchange_portfolio_account? return transactions unless coinstats_account.account_id.present? account_id = coinstats_account.account_id.to_s.downcase @@ -94,22 +95,33 @@ class CoinstatsAccount::Transactions::Processor transactions.select do |tx| tx = tx.with_indifferent_access - # Check coin ID in transactions[0].items[0].coin.id (most common location) - coin_id = tx.dig(:transactions, 0, :items, 0, :coin, :id)&.to_s&.downcase - - # Also check coinData for symbol match as fallback + coin_identifier = tx.dig(:coinData, :identifier)&.to_s&.downcase coin_symbol = tx.dig(:coinData, :symbol)&.to_s&.downcase + nested_coin_matches = transaction_items(tx).any? do |item| + coin = item[:coin].to_h.with_indifferent_access + coin[:id]&.to_s&.downcase == account_id || + coin[:identifier]&.to_s&.downcase == account_id || + coin[:symbol]&.to_s&.downcase == account_id + end # Match if coin ID equals account_id, or if symbol matches account name precisely. # We use strict matching to avoid false positives (e.g., "ETH" should not match # "Ethereum Classic" which has symbol "ETC"). The symbol must appear as: # - A whole word (bounded by word boundaries), OR # - Inside parentheses like "(ETH)" which is common in wallet naming conventions - coin_id == account_id || + nested_coin_matches || + coin_identifier == account_id || (coin_symbol.present? && symbol_matches_name?(coin_symbol, coinstats_account.name)) end end + def transaction_items(transaction) + tx = transaction.with_indifferent_access + + Array(tx[:transactions]).flat_map { |entry| Array(entry.with_indifferent_access[:items]) } + + Array(tx[:transfers]).flat_map { |entry| Array(entry.with_indifferent_access[:items]) } + end + # Checks if a coin symbol matches the account name using strict matching. # Avoids false positives from partial substring matches (e.g., "ETH" matching # "Ethereum Classic (0x123...)" which should only match "ETC"). diff --git a/app/models/coinstats_account/value_helpers.rb b/app/models/coinstats_account/value_helpers.rb new file mode 100644 index 000000000..5e8f7164e --- /dev/null +++ b/app/models/coinstats_account/value_helpers.rb @@ -0,0 +1,88 @@ +module CoinstatsAccount::ValueHelpers + extend ActiveSupport::Concern + + private + def family_currency + parse_currency(coinstats_item&.family&.currency) + end + + def preferred_exchange_currency + family_currency.presence || "USD" + end + + def exchange_rate_available?(from:, to:) + return true if from == to + + ExchangeRate.find_or_fetch_rate(from: from, to: to, date: Date.current).present? + rescue StandardError => e + Rails.logger.warn("CoinstatsAccount #{id} - Failed to load FX #{from}/#{to}: #{e.class} - #{e.message}") + false + end + + def converted_usd_amount(raw_usd_amount, target_currency) + return raw_usd_amount if raw_usd_amount.blank? + return raw_usd_amount if target_currency == "USD" + + usd_amount = parse_decimal(raw_usd_amount) + return if usd_amount.zero? && raw_usd_amount.to_s != "0" + + return unless exchange_rate_available?(from: "USD", to: target_currency) + + Money.new(usd_amount, "USD").exchange_to(target_currency).amount + rescue StandardError => e + Rails.logger.warn("CoinstatsAccount #{id} - Failed to convert USD -> #{target_currency}: #{e.class} - #{e.message}") + nil + end + + def asset_metadata(payload) + payload = payload.to_h.with_indifferent_access + metadata = payload[:coin] + metadata.is_a?(Hash) ? metadata.with_indifferent_access : payload + end + + def extract_currency_amount(value, currency) + return parse_decimal(value) unless value.is_a?(Hash) + + values = value.with_indifferent_access + target_currency = parse_currency(currency) || currency || "USD" + + parse_decimal( + values[target_currency] || + values[target_currency.to_s] || + converted_usd_amount(values[:USD] || values["USD"], target_currency) + ) + end + + def exchange_value_payload?(payload) + exchange_source_for?(payload) || exchange_portfolio_source_for?(payload) + end + + def exchange_scalar_value(explicit_value, coin_payload, currency:) + target_currency = parse_currency(currency) || currency || "USD" + return parse_decimal(explicit_value) if target_currency == "USD" + + price_based_value = asset_quantity(coin_payload).abs * asset_price(coin_payload, currency: target_currency) + return price_based_value if price_based_value.positive? + + converted_value = converted_usd_amount(explicit_value, target_currency) + return parse_decimal(converted_value) if converted_value.present? + + parse_decimal(explicit_value) + end + + def fiat_identifier?(value) + value.to_s.start_with?("FiatCoin") + end + + def parse_decimal(value) + return 0.to_d if value.blank? + + BigDecimal(value.to_s) + rescue ArgumentError + 0.to_d + end + + def log_invalid_currency(currency_value) + Rails.logger.warn("Invalid currency code '#{currency_value}' for CoinstatsAccount #{id}, defaulting to USD") + end +end diff --git a/app/models/coinstats_entry/processor.rb b/app/models/coinstats_entry/processor.rb index 6b966f60b..ce1731b4c 100644 --- a/app/models/coinstats_entry/processor.rb +++ b/app/models/coinstats_entry/processor.rb @@ -14,6 +14,8 @@ class CoinstatsEntry::Processor include CoinstatsTransactionIdentifiable + EXCHANGE_TRADE_TYPES = %w[buy sell swap trade convert fill].freeze + # @param coinstats_transaction [Hash] Raw transaction data from API # @param coinstats_account [CoinstatsAccount] Parent account for context def initialize(coinstats_transaction, coinstats_account:) @@ -31,17 +33,39 @@ class CoinstatsEntry::Processor return nil end - import_adapter.import_transaction( - external_id: external_id, - amount: amount, - currency: currency, - date: date, - name: name, - source: "coinstats", - merchant: merchant, - notes: notes, - extra: extra_metadata - ) + if exchange_trade? && trade_security.present? + return legacy_transaction_entry if skip_legacy_transaction_migration? + + Account.transaction do + remove_legacy_transaction_entry! + + import_adapter.import_trade( + external_id: external_id, + security: trade_security, + quantity: trade_quantity, + price: trade_price, + amount: trade_amount, + currency: currency, + date: date, + name: name, + source: "coinstats", + activity_label: trade_activity_label + ) + end + else + import_adapter.import_transaction( + external_id: external_id, + amount: amount, + currency: currency, + date: date, + name: name, + source: "coinstats", + merchant: merchant, + notes: notes, + extra: extra_metadata, + investment_activity_label: transaction_activity_label + ) + end rescue ArgumentError => e Rails.logger.error "CoinstatsEntry::Processor - Validation error for transaction #{external_id rescue 'unknown'}: #{e.message}" raise @@ -76,6 +100,15 @@ class CoinstatsEntry::Processor cs["count"] = coin_data[:count] if coin_data[:count].present? end + if matched_item.present? + cs["matched_item"] = { + "count" => matched_item[:count], + "total_worth" => matched_item[:totalWorth], + "coin_id" => matched_item.dig(:coin, :id), + "coin_symbol" => matched_item.dig(:coin, :symbol) + }.compact + end + # Store profit/loss info if profit_loss.present? cs["profit"] = profit_loss[:profit] if profit_loss[:profit].present? @@ -86,7 +119,10 @@ class CoinstatsEntry::Processor if fee_data.present? cs["fee_amount"] = fee_data[:count] if fee_data[:count].present? cs["fee_symbol"] = fee_data.dig(:coin, :symbol) if fee_data.dig(:coin, :symbol).present? - cs["fee_usd"] = fee_data[:totalWorth] if fee_data[:totalWorth].present? + if fee_data[:totalWorth].present? + cs["fee_value"] = fee_data[:totalWorth] + cs["fee_usd"] = fee_data[:totalWorth] + end end return nil if cs.empty? @@ -103,7 +139,7 @@ class CoinstatsEntry::Processor def data @data ||= coinstats_transaction.with_indifferent_access - end + end # Helper accessors for nested data structures def hash_data @@ -127,7 +163,7 @@ class CoinstatsEntry::Processor end def transaction_type - data[:type] + data[:type] || data[:transactionType] end def external_id @@ -138,7 +174,7 @@ class CoinstatsEntry::Processor def name tx_type = transaction_type || "Transaction" - symbol = coin_data[:symbol] + symbol = matched_symbol || coin_data[:symbol] # Get coin name from nested transaction items if available (used as fallback) coin_name = transactions_data.dig(0, :items, 0, :coin, :name) @@ -153,14 +189,33 @@ class CoinstatsEntry::Processor end def amount - # Use currentValue from coinData (USD value) or profitLoss - usd_value = coin_data[:currentValue] || profit_loss[:currentValue] || 0 + if portfolio_exchange_account? + absolute_amount = matched_item_total_worth.abs.nonzero? || + coin_data[:currentValue]&.to_d&.abs&.nonzero? || + profit_loss[:currentValue]&.to_d&.abs&.nonzero? || + 0.to_d - parsed_amount = case usd_value + return portfolio_outflow? ? absolute_amount : -absolute_amount + end + + if coinstats_account.exchange_source? && coinstats_account.fiat_asset? + fiat_value = matched_item_total_worth.abs + absolute_amount = fiat_value.positive? ? fiat_value : coin_data[:count].to_d.abs + return outgoing_transaction_type? ? absolute_amount : -absolute_amount + end + + raw_value = + if coinstats_account.exchange_source? + matched_item_total_worth.nonzero? || coin_data[:currentValue] || profit_loss[:currentValue] || 0 + else + coin_data[:currentValue] || profit_loss[:currentValue] || 0 + end + + parsed_amount = case raw_value when String - BigDecimal(usd_value) + BigDecimal(raw_value) when Numeric - BigDecimal(usd_value.to_s) + BigDecimal(raw_value.to_s) else BigDecimal("0") end @@ -179,7 +234,7 @@ class CoinstatsEntry::Processor -absolute_amount end rescue ArgumentError => e - Rails.logger.error "Failed to parse CoinStats transaction amount: #{usd_value.inspect} - #{e.message}" + Rails.logger.error "Failed to parse CoinStats transaction amount: #{data.inspect} - #{e.message}" raise end @@ -189,8 +244,7 @@ class CoinstatsEntry::Processor end def currency - # CoinStats values are always in USD - "USD" + account.currency || coinstats_account.currency || "USD" end def date @@ -242,8 +296,8 @@ class CoinstatsEntry::Processor parts = [] # Include coin/token details with count - symbol = coin_data[:symbol] - count = coin_data[:count] + symbol = matched_symbol || coin_data[:symbol] + count = trade_item_count.nonzero? || coin_data[:count] if count.present? && symbol.present? parts << "#{count} #{symbol}" end @@ -257,7 +311,7 @@ class CoinstatsEntry::Processor if profit_loss[:profit].present? profit_formatted = profit_loss[:profit].to_f.round(2) percent_formatted = profit_loss[:profitPercent].to_f.round(2) - parts << "P/L: $#{profit_formatted} (#{percent_formatted}%)" + parts << "P/L: #{formatted_currency_amount(profit_formatted)} (#{percent_formatted}%)" end # Include explorer URL for reference @@ -267,4 +321,170 @@ class CoinstatsEntry::Processor parts.presence&.join(" | ") end + + def exchange_trade? + return false unless coinstats_account.exchange_source? + return false if coinstats_account.fiat_asset? + return false if trade_quantity.zero? || trade_price.zero? + + EXCHANGE_TRADE_TYPES.include?(normalized_transaction_type) + end + + def trade_security + symbol = trade_item&.dig(:coin, :symbol) || matched_symbol || coinstats_account.asset_symbol + return if symbol.blank? + + Security::Resolver.new(symbol.start_with?("CRYPTO:") ? symbol : "CRYPTO:#{symbol}").resolve + end + + def trade_quantity + trade_item_count.nonzero? || matched_item_count.nonzero? || coin_data[:count].to_d + end + + def trade_price + @trade_price ||= begin + quantity = trade_quantity.abs + return 0.to_d if quantity.zero? + + value = trade_item_total_worth.nonzero? || matched_item_total_worth.nonzero? || coin_data[:currentValue] || coin_data[:totalWorth] || profit_loss[:currentValue] || 0 + BigDecimal(value.to_s).abs / quantity + rescue ArgumentError + 0.to_d + end + end + + def trade_amount + trade_quantity * trade_price + end + + def trade_activity_label + normalized_transaction_type == "sell" || trade_quantity.negative? ? "Sell" : "Buy" + end + + def transaction_activity_label + case normalized_transaction_type + when "buy" then "Buy" + when "sell" then "Sell" + when "swap", "trade", "convert" then "Other" + when "received", "receive", "deposit", "transfer_in", "roll_in" then "Transfer" + when "sent", "send", "withdraw", "transfer_out", "roll_out" then "Transfer" + when "reward", "interest" then "Interest" + when "dividend" then "Dividend" + when "fee" then "Fee" + else + "Other" + end + end + + def normalized_transaction_type + @normalized_transaction_type ||= transaction_type.to_s.downcase.parameterize(separator: "_") + end + + def remove_legacy_transaction_entry! + legacy_transaction_entry&.destroy! + end + + def legacy_transaction_entry + @legacy_transaction_entry ||= account.entries.find_by( + external_id: external_id, + source: "coinstats", + entryable_type: "Transaction" + ) + end + + def skip_legacy_transaction_migration? + return false unless legacy_transaction_entry.present? + + skip_reason = import_adapter.send(:determine_skip_reason, legacy_transaction_entry) + return false if skip_reason.blank? + + import_adapter.send(:record_skip, legacy_transaction_entry, skip_reason) + true + end + + def matched_symbol + matched_item&.dig(:coin, :symbol) + end + + def matched_item + @matched_item ||= begin + return primary_portfolio_item if portfolio_exchange_account? + + items = transaction_items + account_id = coinstats_account.account_id.to_s.downcase + account_symbol = coinstats_account.asset_symbol.to_s.downcase + + items.find do |item| + coin = item[:coin].to_h.with_indifferent_access + coin[:id]&.to_s&.downcase == account_id || + coin[:identifier]&.to_s&.downcase == account_id || + coin[:symbol]&.to_s&.downcase == account_symbol + end + end + end + + def trade_item + @trade_item ||= portfolio_exchange_account? ? portfolio_trade_item : matched_item + end + + def trade_item_count + trade_item&.[](:count).to_d + end + + def trade_item_total_worth + trade_item&.[](:totalWorth).to_d + end + + def matched_item_count + matched_item&.[](:count).to_d + end + + def matched_item_total_worth + matched_item&.[](:totalWorth).to_d + end + + def portfolio_exchange_account? + coinstats_account.exchange_portfolio_account? + end + + def portfolio_trade_item + crypto_items = transaction_items.reject { |item| portfolio_fiat_item?(item) || item[:count].to_d.zero? } + crypto_items.find { |item| item[:count].to_d.negative? } || + crypto_items.find { |item| item[:count].to_d.positive? } || + crypto_items.first + end + + def primary_portfolio_item + portfolio_trade_item || + transaction_items.find { |item| item[:count].to_d.nonzero? } || + transaction_items.first + end + + def portfolio_fiat_item?(item) + coinstats_account.fiat_asset?(item[:coin] || item) + end + + def transaction_items + @transaction_items ||= begin + Array(transactions_data).flat_map do |entry| + Array(entry.with_indifferent_access[:items]).map(&:with_indifferent_access) + end + + Array(data[:transfers]).flat_map do |entry| + Array(entry.with_indifferent_access[:items]).map(&:with_indifferent_access) + end + end + end + + def portfolio_outflow? + outgoing_transaction_type? || + trade_item_count.negative? || + matched_item_count.negative? || + coin_data[:count].to_d.negative? + end + + def formatted_currency_amount(amount) + return "$#{amount}" if currency == "USD" + + "#{amount} #{currency}" + end end diff --git a/app/models/coinstats_item.rb b/app/models/coinstats_item.rb index 6df76bf70..6a1bf8291 100644 --- a/app/models/coinstats_item.rb +++ b/app/models/coinstats_item.rb @@ -1,5 +1,5 @@ # Represents a CoinStats API connection for a family. -# Stores credentials and manages associated crypto wallet accounts. +# Stores credentials and manages associated wallet and exchange portfolio accounts. class CoinstatsItem < ApplicationRecord include Syncable, Provided, Unlinking @@ -20,6 +20,7 @@ class CoinstatsItem < ApplicationRecord validates :name, presence: true validates :api_key, presence: true + validates :exchange_portfolio_id, uniqueness: { scope: :family_id, allow_nil: true } belongs_to :family has_one_attached :logo, dependent: :purge_later @@ -141,11 +142,15 @@ class CoinstatsItem < ApplicationRecord # @return [String] Display name for the CoinStats connection def institution_display_name - name.presence || "CoinStats" + institution_name.presence || name.presence || "CoinStats" end # @return [Boolean] true if API key is set def credentials_configured? api_key.present? end + + def exchange_configured? + exchange_portfolio_id.present? && exchange_connection_id.present? + end end diff --git a/app/models/coinstats_item/exchange_linker.rb b/app/models/coinstats_item/exchange_linker.rb new file mode 100644 index 000000000..c8cb1d4bf --- /dev/null +++ b/app/models/coinstats_item/exchange_linker.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +class CoinstatsItem::ExchangeLinker + Result = Struct.new(:success?, :created_count, :errors, keyword_init: true) + + attr_reader :coinstats_item, :connection_id, :connection_fields, :name + + def initialize(coinstats_item, connection_id:, connection_fields:, name: nil) + @coinstats_item = coinstats_item + @connection_id = connection_id + @connection_fields = connection_fields.to_h.compact_blank + @name = name + end + + def link + return Result.new(success?: false, created_count: 0, errors: [ "Exchange is required" ]) if connection_id.blank? + return Result.new(success?: false, created_count: 0, errors: [ "Exchange credentials are required" ]) if connection_fields.blank? + + created_count = 0 + exchange = fetch_exchange_definition + validate_required_fields!(exchange) + + response = provider.connect_portfolio_exchange( + connection_id: connection_id, + connection_fields: connection_fields, + name: name.presence || default_portfolio_name(exchange) + ) + + return Result.new(success?: false, created_count: 0, errors: [ response.error.message ]) unless response.success? + + payload = response.data.with_indifferent_access + portfolio_id = payload[:portfolioId] + raise Provider::Coinstats::Error, "CoinStats did not return a portfolioId" if portfolio_id.blank? + + coins = provider.list_portfolio_coins(portfolio_id: portfolio_id) + + ActiveRecord::Base.transaction do + coinstats_item.update!( + exchange_connection_id: connection_id, + exchange_portfolio_id: portfolio_id, + institution_id: connection_id, + institution_name: exchange[:name], + raw_institution_payload: exchange + ) + + if coins.nil? + Rails.logger.warn "CoinstatsItem::ExchangeLinker - Initial portfolio coin fetch missing for item #{coinstats_item.id} portfolio #{portfolio_id}; deferring local account creation to background sync" + else + coinstats_account = exchange_portfolio_account_manager.upsert_account!( + coins_data: coins, + portfolio_id: portfolio_id, + connection_id: exchange[:connection_id], + exchange_name: exchange[:name], + account_name: name.presence || exchange[:name], + institution_logo: exchange[:icon] + ) + created_count = exchange_portfolio_account_manager.ensure_local_account!(coinstats_account) ? 1 : 0 + end + end + + coinstats_item.sync_later + + Result.new(success?: true, created_count: created_count, errors: []) + rescue Provider::Coinstats::Error, ArgumentError => e + Result.new(success?: false, created_count: 0, errors: [ e.message ]) + end + + private + def provider + @provider ||= Provider::Coinstats.new(coinstats_item.api_key) + end + + def exchange_portfolio_account_manager + @exchange_portfolio_account_manager ||= CoinstatsItem::ExchangePortfolioAccountManager.new(coinstats_item) + end + + def fetch_exchange_definition + exchange = provider.exchange_options.find { |option| option[:connection_id] == connection_id } + raise ArgumentError, "Unsupported exchange connection: #{connection_id}" unless exchange + + exchange + end + + def validate_required_fields!(exchange) + missing_fields = Array(exchange[:connection_fields]).filter_map do |field| + key = field[:key].to_s + field[:name] if key.blank? || connection_fields[key].blank? + end + + return if missing_fields.empty? + + raise ArgumentError, "Missing required exchange fields: #{missing_fields.join(', ')}" + end + + def default_portfolio_name(exchange) + "#{exchange[:name]} Portfolio" + end +end diff --git a/app/models/coinstats_item/exchange_portfolio_account_manager.rb b/app/models/coinstats_item/exchange_portfolio_account_manager.rb new file mode 100644 index 000000000..80ac16a6e --- /dev/null +++ b/app/models/coinstats_item/exchange_portfolio_account_manager.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +class CoinstatsItem::ExchangePortfolioAccountManager + attr_reader :coinstats_item + + def initialize(coinstats_item) + @coinstats_item = coinstats_item + end + + def upsert_account!(coins_data:, portfolio_id:, connection_id:, exchange_name:, account_name:, institution_logo: nil) + coinstats_account = coinstats_item.coinstats_accounts.find_or_initialize_by( + account_id: portfolio_account_id(portfolio_id), + wallet_address: portfolio_id + ) + + coinstats_account.name = account_name + coinstats_account.provider = exchange_name + coinstats_account.account_status = "active" + coinstats_account.wallet_address = portfolio_id + coinstats_account.institution_metadata = { + logo: institution_logo, + exchange_logo: institution_logo + }.compact + coinstats_account.raw_payload = build_snapshot( + coins_data: coins_data, + portfolio_id: portfolio_id, + connection_id: connection_id, + exchange_name: exchange_name, + account_name: account_name, + institution_logo: institution_logo + ) + coinstats_account.currency = coinstats_account.inferred_currency + coinstats_account.current_balance = coinstats_account.inferred_current_balance + coinstats_account.save! + coinstats_account + end + + def ensure_local_account!(coinstats_account) + return false if coinstats_account.account.present? + + attributes = { + family: coinstats_item.family, + name: coinstats_account.name, + balance: coinstats_account.current_balance || 0, + cash_balance: coinstats_account.inferred_cash_balance, + currency: coinstats_account.currency || coinstats_item.family.currency || "USD", + accountable_type: "Crypto", + accountable_attributes: { + subtype: "exchange", + tax_treatment: "taxable" + } + } + + account = Account.create_and_sync(attributes, skip_initial_sync: true) + AccountProvider.create!(account: account, provider: coinstats_account) + true + end + + def portfolio_account_id(portfolio_id) + "exchange_portfolio:#{portfolio_id}" + end + + private + def build_snapshot(coins_data:, portfolio_id:, connection_id:, exchange_name:, account_name:, institution_logo:) + { + source: "exchange", + portfolio_account: true, + portfolio_id: portfolio_id, + connection_id: connection_id, + exchange_name: exchange_name, + id: portfolio_account_id(portfolio_id), + name: account_name, + institution_logo: institution_logo, + coins: Array(coins_data).map(&:to_h) + } + end +end diff --git a/app/models/coinstats_item/importer.rb b/app/models/coinstats_item/importer.rb index 8cf4e21f3..9f48dd71a 100644 --- a/app/models/coinstats_item/importer.rb +++ b/app/models/coinstats_item/importer.rb @@ -19,6 +19,7 @@ class CoinstatsItem::Importer # CoinStats works differently from bank providers - wallets are added manually # via the setup_accounts flow. During sync, we just update existing linked accounts. + sync_exchange_accounts! # Get all linked coinstats accounts (ones with account_provider associations) linked_accounts = coinstats_item.coinstats_accounts @@ -34,15 +35,30 @@ class CoinstatsItem::Importer accounts_failed = 0 transactions_imported = 0 - # Fetch balance data using bulk endpoint - bulk_balance_data = fetch_balances_for_accounts(linked_accounts) + wallet_accounts = linked_accounts.select(&:wallet_source?) + exchange_accounts = linked_accounts.select(&:exchange_source?) - # Fetch transaction data using bulk endpoint - bulk_transactions_data = fetch_transactions_for_accounts(linked_accounts) + bulk_balance_data = fetch_balances_for_accounts(wallet_accounts) + bulk_transactions_data = fetch_transactions_for_accounts(wallet_accounts) + portfolio_coins_data = fetch_portfolio_coins_for_exchange(exchange_accounts) + portfolio_transactions_data = fetch_portfolio_transactions_for_exchange(exchange_accounts) linked_accounts.each do |coinstats_account| begin - result = update_account(coinstats_account, bulk_balance_data: bulk_balance_data, bulk_transactions_data: bulk_transactions_data) + result = + if coinstats_account.exchange_source? + update_exchange_account( + coinstats_account, + portfolio_coins_data: portfolio_coins_data, + portfolio_transactions_data: portfolio_transactions_data + ) + else + update_wallet_account( + coinstats_account, + bulk_balance_data: bulk_balance_data, + bulk_transactions_data: bulk_transactions_data + ) + end accounts_updated += 1 if result[:success] transactions_imported += result[:transactions_count] || 0 rescue => e @@ -63,6 +79,26 @@ class CoinstatsItem::Importer private + def sync_exchange_accounts! + return unless coinstats_item.exchange_configured? + return if coinstats_item.coinstats_accounts.any?(&:exchange_source?) + + exchange_portfolio_configurations.each do |config| + portfolio_coins = coinstats_provider.list_portfolio_coins(portfolio_id: config[:portfolio_id]) + coinstats_account = exchange_portfolio_account_manager.upsert_account!( + coins_data: portfolio_coins, + portfolio_id: config[:portfolio_id], + connection_id: config[:connection_id], + exchange_name: config[:exchange_name], + account_name: config[:exchange_name], + institution_logo: coinstats_item.raw_institution_payload.to_h.with_indifferent_access[:icon] + ) + exchange_portfolio_account_manager.ensure_local_account!(coinstats_account) + end + rescue => e + Rails.logger.warn "CoinstatsItem::Importer - Exchange account discovery failed: #{e.message}" + end + # Fetch balance data for all linked accounts using the bulk endpoint # @param linked_accounts [Array] Accounts to fetch balances for # @return [Array, nil] Bulk balance data, or nil on error @@ -89,6 +125,18 @@ class CoinstatsItem::Importer nil end + def fetch_portfolio_coins_for_exchange(linked_accounts) + return {} if linked_accounts.empty? && !coinstats_item.exchange_configured? + + exchange_portfolio_configurations(linked_accounts).each_with_object({}) do |config, results| + Rails.logger.info "CoinstatsItem::Importer - Fetching portfolio coins for CoinStats exchange #{config[:portfolio_id]}" + results[config[:portfolio_id]] = coinstats_provider.list_portfolio_coins(portfolio_id: config[:portfolio_id]) + end + rescue => e + Rails.logger.warn "CoinstatsItem::Importer - Portfolio coins fetch failed: #{e.message}" + {} + end + # Fetch transaction data for all linked accounts using the bulk endpoint # @param linked_accounts [Array] Accounts to fetch transactions for # @return [Array, nil] Bulk transaction data, or nil on error @@ -115,12 +163,49 @@ class CoinstatsItem::Importer nil end + def fetch_portfolio_transactions_for_exchange(linked_accounts) + return {} if linked_accounts.empty? && !coinstats_item.exchange_configured? + + from = coinstats_item.sync_start_date&.iso8601 + + exchange_portfolio_configurations(linked_accounts).each_with_object({}) do |config, results| + Rails.logger.info "CoinstatsItem::Importer - Fetching exchange transactions for CoinStats exchange #{config[:portfolio_id]} in #{family_currency}" + + begin + coinstats_provider.sync_exchange(portfolio_id: config[:portfolio_id]) + + results[config[:portfolio_id]] = coinstats_provider.list_exchange_transactions( + portfolio_id: config[:portfolio_id], + currency: family_currency, + from: from + ) + rescue => e + Rails.logger.warn "CoinstatsItem::Importer - Exchange transactions fetch failed for #{config[:portfolio_id]}: #{e.message}; falling back to portfolio transactions" + + begin + coinstats_provider.sync_portfolio(portfolio_id: config[:portfolio_id]) + results[config[:portfolio_id]] = coinstats_provider.list_portfolio_transactions( + portfolio_id: config[:portfolio_id], + currency: family_currency, + from: from + ) + rescue => fallback_error + Rails.logger.warn "CoinstatsItem::Importer - Portfolio transaction fallback failed for #{config[:portfolio_id]}: #{fallback_error.message}" + results[config[:portfolio_id]] = [] + end + end + end + rescue => e + Rails.logger.warn "CoinstatsItem::Importer - Exchange transactions fetch failed: #{e.message}" + {} + end + # Updates a single account with balance and transaction data. # @param coinstats_account [CoinstatsAccount] Account to update # @param bulk_balance_data [Array, nil] Pre-fetched balance data # @param bulk_transactions_data [Array, nil] Pre-fetched transaction data # @return [Hash] Result with :success and :transactions_count - def update_account(coinstats_account, bulk_balance_data:, bulk_transactions_data:) + def update_wallet_account(coinstats_account, bulk_balance_data:, bulk_transactions_data:) # Get the wallet address and blockchain from the raw payload raw = coinstats_account.raw_payload || {} address = raw["address"] || raw[:address] @@ -147,6 +232,35 @@ class CoinstatsItem::Importer { success: true, transactions_count: transactions_count } end + def update_exchange_account(coinstats_account, portfolio_coins_data:, portfolio_transactions_data:) + portfolio_id = exchange_portfolio_id_for(coinstats_account) + balance_data = portfolio_coins_data[portfolio_id] + + if coinstats_account.exchange_portfolio_account? + if !balance_data.nil? + coinstats_account.upsert_coinstats_snapshot!( + normalize_exchange_portfolio_data(balance_data, coinstats_account, portfolio_id: portfolio_id) + ) + else + Rails.logger.warn "CoinstatsItem::Importer - Missing exchange portfolio coin data for account #{coinstats_account.id} (portfolio #{portfolio_id}); preserving previous snapshot" + end + else + matching_coin = find_matching_portfolio_coin(balance_data, coinstats_account) + + if matching_coin.present? + coinstats_account.upsert_coinstats_snapshot!( + normalize_portfolio_coin_data(matching_coin, coinstats_account, portfolio_id: portfolio_id) + ) + else + Rails.logger.warn "CoinstatsItem::Importer - No matching exchange coin found for account #{coinstats_account.id} (#{coinstats_account.account_id}) in portfolio #{portfolio_id}; preserving previous snapshot" + end + end + + transactions_count = fetch_and_merge_portfolio_transactions(coinstats_account, portfolio_transactions_data[portfolio_id]) + + { success: true, transactions_count: transactions_count } + end + # Extracts and merges new transactions for an account. # Deduplicates by transaction ID to avoid duplicate imports. # @param coinstats_account [CoinstatsAccount] Account to update @@ -192,6 +306,34 @@ class CoinstatsItem::Importer relevant_transactions.count end + def fetch_and_merge_portfolio_transactions(coinstats_account, portfolio_transactions_data) + return 0 if portfolio_transactions_data.blank? + + relevant_transactions = + if coinstats_account.exchange_portfolio_account? + Array(portfolio_transactions_data) + else + filter_transactions_by_coin(portfolio_transactions_data, coinstats_account.account_id) + end + return 0 if relevant_transactions.empty? + + existing_transactions = coinstats_account.raw_transactions_payload.to_a + existing_ids = existing_transactions.map { |tx| extract_coinstats_transaction_id(tx) }.compact.to_set + + transactions_to_add = relevant_transactions.select do |tx| + tx_id = extract_coinstats_transaction_id(tx) + tx_id.present? && !existing_ids.include?(tx_id) + end + + if transactions_to_add.any? + merged_transactions = existing_transactions + transactions_to_add + coinstats_account.upsert_coinstats_transactions_snapshot!(merged_transactions) + Rails.logger.info "CoinstatsItem::Importer - Added #{transactions_to_add.count} new exchange transactions for account #{coinstats_account.id}" + end + + relevant_transactions.count + end + # Filter transactions to only include those relevant to a specific coin # Transactions can be matched by: # - coinData.symbol matching the coin (case-insensitive) @@ -206,10 +348,11 @@ class CoinstatsItem::Importer transactions.select do |tx| tx = tx.with_indifferent_access + coin_identifier = tx.dig(:coinData, :identifier)&.to_s&.downcase # Check nested transactions items for coin match inner_transactions = tx[:transactions] || [] - inner_transactions.any? do |inner_tx| + matches_nested_item = inner_transactions.any? do |inner_tx| inner_tx = inner_tx.with_indifferent_access items = inner_tx[:items] || [] items.any? do |item| @@ -218,9 +361,29 @@ class CoinstatsItem::Importer next false unless coin.present? coin = coin.with_indifferent_access - coin[:id]&.downcase == coin_id_downcase + coin[:id]&.downcase == coin_id_downcase || + coin[:identifier]&.downcase == coin_id_downcase || + coin[:symbol]&.downcase == coin_id_downcase end end + + transfer_transactions = tx[:transfers] || [] + matches_transfer_item = transfer_transactions.any? do |transfer_tx| + transfer_tx = transfer_tx.with_indifferent_access + items = transfer_tx[:items] || [] + items.any? do |item| + item = item.with_indifferent_access + coin = item[:coin] + next false unless coin.present? + + coin = coin.with_indifferent_access + coin[:id]&.downcase == coin_id_downcase || + coin[:identifier]&.downcase == coin_id_downcase || + coin[:symbol]&.downcase == coin_id_downcase + end + end + + coin_identifier == coin_id_downcase || matches_nested_item || matches_transfer_item end end @@ -237,23 +400,21 @@ class CoinstatsItem::Importer # Find the matching token for this account to extract id, logo, and balance matching_token = find_matching_token(balance_data, coinstats_account) - # Calculate balance from the matching token only, not all tokens - # Each coinstats_account represents a single token/coin in the wallet - token_balance = calculate_token_balance(matching_token) + source_snapshot = (matching_token || {}).to_h.with_indifferent_access { # Use existing account_id if set, otherwise extract from matching token id: coinstats_account.account_id.presence || matching_token&.dig(:coinId) || matching_token&.dig(:id), name: coinstats_account.name, - balance: token_balance, - currency: "USD", # CoinStats returns values in USD + balance: coinstats_account.inferred_current_balance(source_snapshot), + currency: coinstats_account.inferred_currency(source_snapshot), address: existing_raw["address"] || existing_raw[:address], blockchain: existing_raw["blockchain"] || existing_raw[:blockchain], # Extract logo from the matching token institution_logo: matching_token&.dig(:imgUrl), # Preserve original data raw_balance_data: balance_data - } + }.merge(source_snapshot.slice(:amount, :count, :price, :priceUsd, :symbol, :coinId, :isFiat, :imgUrl)) end # Finds the token in balance_data that matches this account. @@ -302,14 +463,117 @@ class CoinstatsItem::Importer end end - # Calculates USD balance from token amount and price. - # @param token [Hash, nil] Token with :amount/:balance and :price/:priceUsd - # @return [Float] Balance in USD (0 if token is nil) - def calculate_token_balance(token) - return 0 if token.blank? + def find_matching_portfolio_coin(balance_data, coinstats_account) + Array(balance_data).map(&:with_indifferent_access).find do |coin_data| + coin = coin_data[:coin].to_h.with_indifferent_access + identifier = coin[:identifier].presence || coin_data[:coinId].presence + symbol = coin[:symbol].presence || coin_data[:symbol].presence + base_name = coinstats_account.name.to_s.sub(/\s+\([^)]*\)\z/, "").downcase - amount = token[:amount] || token[:balance] || 0 - price = token[:price] || token[:priceUsd] || 0 - (amount.to_f * price.to_f) + identifier.to_s.casecmp?(coinstats_account.account_id.to_s) || + symbol.to_s.casecmp?(coinstats_account.account_id.to_s) || + symbol.to_s.casecmp?(coinstats_account.asset_symbol.to_s) || + coin[:name].to_s.downcase == base_name + end + end + + def normalize_portfolio_coin_data(balance_data, coinstats_account, portfolio_id:) + existing_raw = coinstats_account.raw_payload.to_h.with_indifferent_access + portfolio_coin = balance_data.to_h.with_indifferent_access + coin = portfolio_coin[:coin].to_h.with_indifferent_access + source_snapshot = { + source: existing_raw[:source] || "exchange", + portfolio_id: portfolio_id, + connection_id: existing_raw[:connection_id] || coinstats_item.exchange_connection_id, + exchange_name: existing_raw[:exchange_name] || exchange_display_name, + coin: coin + }.merge(portfolio_coin) + + { + id: coin[:identifier].presence || coinstats_account.account_id, + name: coinstats_account.name, + balance: coinstats_account.inferred_current_balance(source_snapshot), + currency: coinstats_account.inferred_currency(source_snapshot), + provider: existing_raw[:exchange_name].presence || exchange_display_name, + account_status: "active", + portfolio_id: portfolio_id, + connection_id: existing_raw[:connection_id] || coinstats_item.exchange_connection_id, + institution_logo: coin[:icon], + raw_balance_data: portfolio_coin + }.merge(existing_raw.slice(:source, :exchange_name)) + .merge(portfolio_coin.slice(:coin, :count, :price, :averageBuy, :averageSell, :profit, :profitPercent, :coinId, :isFiat)) + end + + def normalize_exchange_portfolio_data(balance_data, coinstats_account, portfolio_id:) + existing_raw = coinstats_account.raw_payload.to_h.with_indifferent_access + coins = Array(balance_data).map { |coin| coin.with_indifferent_access.to_h } + + snapshot = existing_raw.merge( + source: "exchange", + portfolio_account: true, + portfolio_id: portfolio_id, + connection_id: existing_raw[:connection_id] || coinstats_item.exchange_connection_id, + exchange_name: existing_raw[:exchange_name] || exchange_display_name, + name: coinstats_account.name, + institution_logo: existing_raw[:institution_logo].presence || coinstats_item.raw_institution_payload.to_h.with_indifferent_access[:icon], + coins: coins + ) + + { + id: coinstats_account.account_id.presence || exchange_portfolio_account_manager.portfolio_account_id(portfolio_id), + name: coinstats_account.name, + balance: coinstats_account.inferred_current_balance(snapshot), + currency: coinstats_account.inferred_currency(snapshot), + provider: snapshot[:exchange_name], + account_status: "active", + portfolio_id: portfolio_id, + connection_id: snapshot[:connection_id], + institution_logo: snapshot[:institution_logo], + portfolio_account: true, + coins: coins + }.merge(snapshot.slice(:source, :exchange_name)) + end + + def exchange_display_name + coinstats_item.institution_name.presence || coinstats_item.exchange_connection_id.to_s.titleize + end + + def exchange_portfolio_account_manager + @exchange_portfolio_account_manager ||= CoinstatsItem::ExchangePortfolioAccountManager.new(coinstats_item) + end + + def exchange_portfolio_configurations(linked_accounts = []) + configurations = [] + + if coinstats_item.exchange_configured? + configurations << { + portfolio_id: coinstats_item.exchange_portfolio_id, + connection_id: coinstats_item.exchange_connection_id, + exchange_name: exchange_display_name + } + end + + Array(linked_accounts).select(&:exchange_source?).each do |account| + raw = account.raw_payload.to_h.with_indifferent_access + portfolio_id = raw[:portfolio_id].presence || account.wallet_address.presence + next if portfolio_id.blank? + + configurations << { + portfolio_id: portfolio_id, + connection_id: raw[:connection_id].presence || coinstats_item.exchange_connection_id, + exchange_name: raw[:exchange_name].presence || exchange_display_name + } + end + + configurations.uniq { |config| config[:portfolio_id] } + end + + def exchange_portfolio_id_for(coinstats_account) + raw = coinstats_account.raw_payload.to_h.with_indifferent_access + raw[:portfolio_id].presence || coinstats_account.wallet_address.presence || coinstats_item.exchange_portfolio_id + end + + def family_currency + coinstats_item.family.currency.presence || "USD" end end diff --git a/app/models/coinstats_item/wallet_linker.rb b/app/models/coinstats_item/wallet_linker.rb index 2840fa35b..0d2c372c6 100644 --- a/app/models/coinstats_item/wallet_linker.rb +++ b/app/models/coinstats_item/wallet_linker.rb @@ -77,27 +77,26 @@ class CoinstatsItem::WalletLinker def create_account_from_token(token_data) token = token_data.with_indifferent_access account_name = build_account_name(token) - current_balance = calculate_balance(token) token_id = (token[:coinId] || token[:id])&.to_s ActiveRecord::Base.transaction do coinstats_account = coinstats_item.coinstats_accounts.create!( name: account_name, currency: "USD", - current_balance: current_balance, + current_balance: 0, account_id: token_id, wallet_address: address ) # Store wallet metadata for future syncs - snapshot = build_snapshot(token, current_balance) + snapshot = build_snapshot(token) coinstats_account.upsert_coinstats_snapshot!(snapshot) account = coinstats_item.family.accounts.create!( accountable: Crypto.new, name: account_name, - balance: current_balance, - cash_balance: current_balance, + balance: coinstats_account.current_balance, + cash_balance: coinstats_account.inferred_cash_balance, currency: coinstats_account.currency, status: "active" ) @@ -132,23 +131,12 @@ class CoinstatsItem::WalletLinker end end - # Calculates USD balance from token amount and price. - # @param token [Hash] Token data with :amount/:balance and :price - # @return [Float] Balance in USD - def calculate_balance(token) - amount = token[:amount] || token[:balance] || token[:current_balance] || 0 - price = token[:price] || 0 - (amount.to_f * price.to_f) - end - # Builds snapshot hash for storing in CoinstatsAccount. # @param token [Hash] Token data from API - # @param current_balance [Float] Calculated USD balance # @return [Hash] Snapshot with balance, address, and metadata - def build_snapshot(token, current_balance) - token.to_h.merge( + def build_snapshot(token) + token.to_h.except("id", :id).merge( id: (token[:coinId] || token[:id])&.to_s, - balance: current_balance, currency: "USD", address: address, blockchain: blockchain, diff --git a/app/models/concerns/qif_parser.rb b/app/models/concerns/qif_parser.rb new file mode 100644 index 000000000..65e8a9ba8 --- /dev/null +++ b/app/models/concerns/qif_parser.rb @@ -0,0 +1,472 @@ +# Parses QIF (Quicken Interchange Format) files. +# +# A QIF file is a plain-text format exported by Quicken. It is divided into +# sections, each introduced by a "!Type:" header line. Records within +# a section are terminated by a "^" line. Each data line starts with a single +# letter field code followed immediately by the value. +# +# Sections handled: +# !Type:Tag – tag definitions (N=name, D=description) +# !Type:Cat – category definitions (N=name, D=description, I=income, E=expense) +# !Type:Security – security definitions (N=name, S=ticker, T=type) +# !Type:CCard / !Type:Bank / !Type:Cash / !Type:Oth L – transactions +# !Type:Invst – investment transactions +# +# Transaction field codes: +# D date M/ D'YY or MM/DD'YYYY +# T amount may include commas, e.g. "-1,234.56" +# U amount same as T (alternate field) +# P payee +# M memo +# L category plain name or [TransferAccount]; /Tag suffix is supported +# N check/ref (not a tag – the check number or reference) +# C cleared X = cleared, * = reconciled +# ^ end of record +# +# Investment-specific field codes (in !Type:Invst records): +# N action Buy, Sell, Div, XIn, XOut, IntInc, CGLong, CGShort, etc. +# Y security security name (matches N field in !Type:Security) +# I price price per share +# Q quantity number of shares +# T total total cash amount of transaction +module QifParser + TRANSACTION_TYPES = %w[CCard Bank Cash Invst Oth\ L Oth\ A].freeze + + # Investment action types that create Trade records (buy or sell shares). + BUY_LIKE_ACTIONS = %w[Buy ReinvDiv Cover].freeze + SELL_LIKE_ACTIONS = %w[Sell ShtSell].freeze + TRADE_ACTIONS = (BUY_LIKE_ACTIONS + SELL_LIKE_ACTIONS).freeze + + # Investment action types that create Transaction records. + INFLOW_TRANSACTION_ACTIONS = %w[Div IntInc XIn CGLong CGShort MiscInc].freeze + OUTFLOW_TRANSACTION_ACTIONS = %w[XOut MiscExp].freeze + + ParsedTransaction = Struct.new( + :date, :amount, :payee, :memo, :category, :tags, :check_num, :cleared, :split, + keyword_init: true + ) + + ParsedCategory = Struct.new(:name, :description, :income, keyword_init: true) + ParsedTag = Struct.new(:name, :description, keyword_init: true) + + ParsedSecurity = Struct.new(:name, :ticker, :security_type, keyword_init: true) + + ParsedInvestmentTransaction = Struct.new( + :date, :action, :security_name, :security_ticker, + :price, :qty, :amount, :memo, :payee, :category, :tags, + keyword_init: true + ) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + # Transcodes raw file bytes to UTF-8. + # Quicken on Windows writes QIF files in a Windows code page that varies by region: + # Windows-1252 – North America, Western Europe + # Windows-1250 – Central/Eastern Europe (Poland, Czech Republic, Hungary, …) + # + # We try each encoding with undef: :raise so we only accept an encoding when + # every byte in the file is defined in that code page. Windows-1252 has five + # undefined byte values (0x81, 0x8D, 0x8F, 0x90, 0x9D); if any are present we + # fall through to Windows-1250 which covers those slots differently. + FALLBACK_ENCODINGS = %w[Windows-1252 Windows-1250].freeze + + def self.normalize_encoding(content) + return content if content.nil? + + binary = content.b # Force ASCII-8BIT; never raises on invalid bytes + + utf8_attempt = binary.dup.force_encoding("UTF-8") + return utf8_attempt if utf8_attempt.valid_encoding? + + FALLBACK_ENCODINGS.each do |encoding| + begin + return binary.encode("UTF-8", encoding) + rescue Encoding::UndefinedConversionError + next + end + end + + # Last resort: replace any remaining undefined bytes rather than raise + binary.encode("UTF-8", "Windows-1252", invalid: :replace, undef: :replace, replace: "") + end + + # Returns true if the content looks like a valid QIF file. + def self.valid?(content) + return false if content.blank? + + binary = content.b + binary.include?("!Type:") + end + + # Returns the transaction account type string (e.g. "CCard", "Bank", "Invst"). + # Skips metadata sections (Tag, Cat, Security, Prices) which are not account data. + def self.account_type(content) + return nil if content.blank? + + content.scan(/^!Type:(.+)/i).flatten + .map(&:strip) + .reject { |t| %w[Tag Cat Security Prices].include?(t) } + .first + end + + # Parses all transactions from the file, excluding the Opening Balance entry. + # Returns an array of ParsedTransaction structs. + def self.parse(content, date_format: "%m/%d/%Y") + return [] unless valid?(content) + + content = normalize_encoding(content) + content = normalize_line_endings(content) + + type = account_type(content) + return [] unless type + + section = extract_section(content, type) + return [] unless section + + parse_records(section).filter_map { |record| build_transaction(record, date_format: date_format) } + end + + # Returns the opening balance entry from the QIF file, if present. + # In Quicken's QIF format, the first transaction of a bank/cash account is often + # an "Opening Balance" record with payee "Opening Balance". This entry is NOT a + # real transaction – it is the account's starting balance. + # + # Returns a hash { date: Date, amount: BigDecimal } or nil. + def self.parse_opening_balance(content, date_format: "%m/%d/%Y") + return nil unless valid?(content) + + content = normalize_encoding(content) + content = normalize_line_endings(content) + + type = account_type(content) + return nil unless type + + section = extract_section(content, type) + return nil unless section + + record = parse_records(section).find { |r| r["P"]&.strip == "Opening Balance" } + return nil unless record + + date = parse_qif_date(record["D"], date_format: date_format) + amount = parse_qif_amount(record["T"] || record["U"]) + return nil unless date && amount + + { date: Date.parse(date), amount: amount.to_d } + end + + # Parses categories from the !Type:Cat section. + # Returns an array of ParsedCategory structs. + def self.parse_categories(content) + return [] if content.blank? + + content = normalize_encoding(content) + content = normalize_line_endings(content) + + section = extract_section(content, "Cat") + return [] unless section + + parse_records(section).filter_map do |record| + next unless record["N"].present? + + ParsedCategory.new( + name: record["N"], + description: record["D"], + income: record.key?("I") && !record.key?("E") + ) + end + end + + # Parses tags from the !Type:Tag section. + # Returns an array of ParsedTag structs. + def self.parse_tags(content) + return [] if content.blank? + + content = normalize_encoding(content) + content = normalize_line_endings(content) + + section = extract_section(content, "Tag") + return [] unless section + + parse_records(section).filter_map do |record| + next unless record["N"].present? + + ParsedTag.new( + name: record["N"], + description: record["D"] + ) + end + end + + # Parses all !Type:Security sections and returns an array of ParsedSecurity structs. + # Each security in a QIF file gets its own !Type:Security header, so we scan + # for all occurrences rather than just the first. + def self.parse_securities(content) + return [] if content.blank? + + content = normalize_encoding(content) + content = normalize_line_endings(content) + + securities = [] + + content.scan(/^!Type:Security[^\n]*\n(.*?)(?=^!Type:|\z)/mi) do |captures| + parse_records(captures[0]).each do |record| + next unless record["N"].present? && record["S"].present? + + securities << ParsedSecurity.new( + name: record["N"].strip, + ticker: record["S"].strip, + security_type: record["T"]&.strip + ) + end + end + + securities + end + + # Parses investment transactions from the !Type:Invst section. + # Uses the !Type:Security sections to resolve security names to tickers. + # Returns an array of ParsedInvestmentTransaction structs. + def self.parse_investment_transactions(content, date_format: "%m/%d/%Y") + return [] unless valid?(content) + + content = normalize_encoding(content) + content = normalize_line_endings(content) + + ticker_by_name = parse_securities(content).each_with_object({}) { |s, h| h[s.name] = s.ticker } + + section = extract_section(content, "Invst") + return [] unless section + + parse_records(section).filter_map { |record| build_investment_transaction(record, ticker_by_name, date_format: date_format) } + end + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + def self.normalize_line_endings(content) + content.gsub(/\r\n/, "\n").gsub(/\r/, "\n") + end + private_class_method :normalize_line_endings + + # Extracts the raw text of a named section (everything after its !Type: header + # up to the next !Type: header or end-of-file). + def self.extract_section(content, type_name) + escaped = Regexp.escape(type_name) + pattern = /^!Type:#{escaped}[^\n]*\n(.*?)(?=^!Type:|\z)/mi + content.match(pattern)&.captures&.first + end + private_class_method :extract_section + + # Splits a section into an array of field-code => value hashes. + # Single-letter codes with no value (e.g. "I", "E", "T") are stored with nil. + # Split transactions (multiple S/$/E lines) are flagged with "_split" => true. + def self.parse_records(section_content) + records = [] + current = {} + + section_content.each_line do |line| + line = line.chomp + next if line.blank? + + if line == "^" + records << current unless current.empty? + current = {} + else + code = line[0] + value = line[1..]&.strip + next unless code + + # Mark records that contain split fields (S = split category, $ = split amount) + current["_split"] = true if code == "S" + + # Flag fields like "I" (income) and "E" (expense) have no meaningful value + current[code] = value.presence + end + end + + records << current unless current.empty? + records + end + private_class_method :parse_records + + def self.build_transaction(record, date_format: "%m/%d/%Y") + # "Opening Balance" is a Quicken convention for the account's starting balance – + # it is not a real transaction and must not be imported as one. + return nil if record["P"]&.strip == "Opening Balance" + + raw_date = record["D"] + raw_amount = record["T"] || record["U"] + + return nil unless raw_date.present? && raw_amount.present? + + date = parse_qif_date(raw_date, date_format: date_format) + amount = parse_qif_amount(raw_amount) + + return nil unless date && amount + + category, tags = parse_category_and_tags(record["L"]) + + ParsedTransaction.new( + date: date, + amount: amount, + payee: record["P"], + memo: record["M"], + category: category, + tags: tags, + check_num: record["N"], + cleared: record["C"], + split: record["_split"] == true + ) + end + private_class_method :build_transaction + + # Separates the category name from any tag(s) appended with a "/" delimiter. + # Transfer accounts are wrapped in brackets – treated as no category. + # + # Examples: + # "Food & Dining" → ["Food & Dining", []] + # "Food & Dining/EUROPE2025" → ["Food & Dining", ["EUROPE2025"]] + # "[TD - Chequing]" → ["", []] + def self.parse_category_and_tags(l_field) + return [ "", [] ] if l_field.blank? + + # Transfer account reference + return [ "", [] ] if l_field.start_with?("[") + + # Quicken uses "--Split--" as a placeholder category for split transactions + return [ "", [] ] if l_field.strip.match?(/\A--Split--\z/i) + + parts = l_field.split("/", 2) + category = parts[0].strip + tags = parts[1].present? ? parts[1].split(":").map(&:strip).reject(&:blank?) : [] + + [ category, tags ] + end + private_class_method :parse_category_and_tags + + # Normalizes a QIF date string into a standard format that Date.strptime can + # handle. QIF files use Quicken-specific conventions: + # + # - Apostrophe as year separator: 6/ 4'20 or 6/ 4'2020 + # - Optional spaces around components: 6/ 4'20 → 6/4/20 + # - Dot separators: 04.06.2020 + # - Dash separators: 04-06-2020 + # + # This method: + # 1. Strips whitespace + # 2. Replaces the Quicken apostrophe with the file's date separator + # 3. Expands 2-digit years to 4-digit (00-99 → 2000-2099, capped at current year) + # 4. Returns a cleaned date string suitable for Date.strptime + def self.normalize_qif_date(date_str) + return nil if date_str.blank? + + s = date_str.strip + + # Replace Quicken apostrophe year separator with the preceding separator + if s.include?("'") + sep = s.match(%r{[/.\-]})&.to_s || "/" + s = s.gsub("'", sep) + end + + # Remove internal spaces (e.g. "6/ 4/20" → "6/4/20") + s = s.gsub(/\s+/, "") + + # Expand 2-digit year at end to 4-digit, but only when the string doesn't + # already contain a 4-digit number (which would be a full year). + if !s.match?(/\d{4}/) && (m = s.match(%r{\A(.+[/.\-])(\d{2})\z})) + short_year = m[2].to_i + full_year = 2000 + short_year + full_year -= 100 if full_year > Date.today.year + s = "#{m[1]}#{full_year}" + end + + s + end + private_class_method :normalize_qif_date + + # Parses a QIF date string into an ISO 8601 date string using the given + # strptime format. The date is first normalized (apostrophe → separator, + # 2-digit year expansion, whitespace removal) before parsing. + # + # +date_format+ should be a strptime format string such as "%m/%d/%Y" or + # "%d.%m.%Y". Defaults to "%m/%d/%Y" (US convention) for backwards + # compatibility. + # Attempts to parse a raw QIF date string with the given format. + # Returns the parsed ISO 8601 date string, or nil if parsing fails. + def self.try_parse_date(date_str, date_format: "%m/%d/%Y") + normalized = normalize_qif_date(date_str) + return nil unless normalized + + Date.strptime(normalized, date_format).iso8601 + rescue Date::Error, ArgumentError + nil + end + + private_class_method def self.parse_qif_date(date_str, date_format: "%m/%d/%Y") + try_parse_date(date_str, date_format: date_format) + end + + # Extracts all raw date strings from D-fields in transaction sections only. + # Skips metadata sections (Cat, Tag, Security) where D means "description". + # Used by Import.detect_date_format to sample dates before parsing. + def self.extract_raw_dates(content) + return [] if content.blank? + + content = normalize_encoding(content) + content = normalize_line_endings(content) + + transaction_sections = TRANSACTION_TYPES.filter_map { |type| extract_section(content, type) } + transaction_sections.flat_map { |section| section.scan(/^D(.+)$/i).flatten } + .map { |d| normalize_qif_date(d) } + .compact + end + + # Strips thousands-separator commas and returns a clean decimal string. + def self.parse_qif_amount(amount_str) + return nil if amount_str.blank? + + cleaned = amount_str.gsub(",", "").strip + cleaned =~ /\A-?\d+\.?\d*\z/ ? cleaned : nil + end + private_class_method :parse_qif_amount + + # Builds a ParsedInvestmentTransaction from a raw record hash. + # ticker_by_name maps security names (N field in !Type:Security) to tickers (S field). + def self.build_investment_transaction(record, ticker_by_name, date_format: "%m/%d/%Y") + action = record["N"]&.strip + return nil unless action.present? + + raw_date = record["D"] + return nil unless raw_date.present? + + date = parse_qif_date(raw_date, date_format: date_format) + return nil unless date + + security_name = record["Y"]&.strip + security_ticker = ticker_by_name[security_name] || security_name + + price = parse_qif_amount(record["I"]) + qty = parse_qif_amount(record["Q"]) + amount = parse_qif_amount(record["T"] || record["U"]) + + category, tags = parse_category_and_tags(record["L"]) + + ParsedInvestmentTransaction.new( + date: date, + action: action, + security_name: security_name, + security_ticker: security_ticker, + price: price, + qty: qty, + amount: amount, + memo: record["M"]&.strip, + payee: record["P"]&.strip, + category: category, + tags: tags + ) + end + private_class_method :build_investment_transaction +end diff --git a/app/models/current.rb b/app/models/current.rb index 86b9e97ac..612c2af76 100644 --- a/app/models/current.rb +++ b/app/models/current.rb @@ -16,4 +16,19 @@ class Current < ActiveSupport::CurrentAttributes def true_user session&.user end + + def accessible_accounts + return family&.accounts unless user + user.accessible_accounts + end + + def finance_accounts + return family&.accounts unless user + user.finance_accounts + end + + def accessible_entries + return family&.entries unless user + family.entries.joins(:account).merge(Account.accessible_by(user)) + end end diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index d44b16c83..474a1095c 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -213,36 +213,36 @@ class Demo::Generator def create_realistic_categories!(family) # Income categories (3 total) - @salary_cat = family.categories.create!(name: "Salary", color: "#10b981", classification: "income") - @freelance_cat = family.categories.create!(name: "Freelance", color: "#059669", classification: "income") - @investment_income_cat = family.categories.create!(name: "Investment Income", color: "#047857", classification: "income") + @salary_cat = family.categories.create!(name: "Salary", color: "#10b981") + @freelance_cat = family.categories.create!(name: "Freelance", color: "#059669") + @investment_income_cat = family.categories.create!(name: "Investment Income", color: "#047857") # Expense categories with subcategories (12 total) - @housing_cat = family.categories.create!(name: "Housing", color: "#dc2626", classification: "expense") - @rent_cat = family.categories.create!(name: "Rent/Mortgage", parent: @housing_cat, color: "#b91c1c", classification: "expense") - @utilities_cat = family.categories.create!(name: "Utilities", parent: @housing_cat, color: "#991b1b", classification: "expense") + @housing_cat = family.categories.create!(name: "Housing", color: "#dc2626") + @rent_cat = family.categories.create!(name: "Rent/Mortgage", parent: @housing_cat, color: "#b91c1c") + @utilities_cat = family.categories.create!(name: "Utilities", parent: @housing_cat, color: "#991b1b") - @food_cat = family.categories.create!(name: "Food & Dining", color: "#ea580c", classification: "expense") - @groceries_cat = family.categories.create!(name: "Groceries", parent: @food_cat, color: "#c2410c", classification: "expense") - @restaurants_cat = family.categories.create!(name: "Restaurants", parent: @food_cat, color: "#9a3412", classification: "expense") - @coffee_cat = family.categories.create!(name: "Coffee & Takeout", parent: @food_cat, color: "#7c2d12", classification: "expense") + @food_cat = family.categories.create!(name: "Food & Dining", color: "#ea580c") + @groceries_cat = family.categories.create!(name: "Groceries", parent: @food_cat, color: "#c2410c") + @restaurants_cat = family.categories.create!(name: "Restaurants", parent: @food_cat, color: "#9a3412") + @coffee_cat = family.categories.create!(name: "Coffee & Takeout", parent: @food_cat, color: "#7c2d12") - @transportation_cat = family.categories.create!(name: "Transportation", color: "#2563eb", classification: "expense") - @gas_cat = family.categories.create!(name: "Gas", parent: @transportation_cat, color: "#1d4ed8", classification: "expense") - @car_payment_cat = family.categories.create!(name: "Car Payment", parent: @transportation_cat, color: "#1e40af", classification: "expense") + @transportation_cat = family.categories.create!(name: "Transportation", color: "#2563eb") + @gas_cat = family.categories.create!(name: "Gas", parent: @transportation_cat, color: "#1d4ed8") + @car_payment_cat = family.categories.create!(name: "Car Payment", parent: @transportation_cat, color: "#1e40af") - @entertainment_cat = family.categories.create!(name: "Entertainment", color: "#7c3aed", classification: "expense") - @healthcare_cat = family.categories.create!(name: "Healthcare", color: "#db2777", classification: "expense") - @shopping_cat = family.categories.create!(name: "Shopping", color: "#059669", classification: "expense") - @travel_cat = family.categories.create!(name: "Travel", color: "#0891b2", classification: "expense") - @personal_care_cat = family.categories.create!(name: "Personal Care", color: "#be185d", classification: "expense") + @entertainment_cat = family.categories.create!(name: "Entertainment", color: "#7c3aed") + @healthcare_cat = family.categories.create!(name: "Healthcare", color: "#db2777") + @shopping_cat = family.categories.create!(name: "Shopping", color: "#059669") + @travel_cat = family.categories.create!(name: "Travel", color: "#0891b2") + @personal_care_cat = family.categories.create!(name: "Personal Care", color: "#be185d") # Additional high-level expense categories to reach 13 top-level items - @insurance_cat = family.categories.create!(name: "Insurance", color: "#6366f1", classification: "expense") - @misc_cat = family.categories.create!(name: "Miscellaneous", color: "#6b7280", classification: "expense") + @insurance_cat = family.categories.create!(name: "Insurance", color: "#6366f1") + @misc_cat = family.categories.create!(name: "Miscellaneous", color: "#6b7280") # Interest expense bucket - @interest_cat = family.categories.create!(name: "Loan Interest", color: "#475569", classification: "expense") + @interest_cat = family.categories.create!(name: "Loan Interest", color: "#475569") end def create_realistic_accounts!(family) @@ -354,11 +354,11 @@ class Demo::Generator analysis_start = (current_month - 3.months).beginning_of_month analysis_period = analysis_start..(current_month - 1.day) - # Fetch expense transactions in the analysis period + # Fetch expense transactions in the analysis period (positive amounts = expenses) txns = Entry.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id") .joins("INNER JOIN categories ON categories.id = transactions.category_id") .where(entries: { entryable_type: "Transaction", date: analysis_period }) - .where(categories: { classification: "expense" }) + .where("entries.amount > 0") spend_per_cat = txns.group("categories.id").sum("entries.amount") diff --git a/app/models/developer_message.rb b/app/models/developer_message.rb deleted file mode 100644 index 3ba9b3ead..000000000 --- a/app/models/developer_message.rb +++ /dev/null @@ -1,10 +0,0 @@ -class DeveloperMessage < Message - def role - "developer" - end - - private - def broadcast? - chat.debug_mode? - end -end diff --git a/app/models/enable_banking_account.rb b/app/models/enable_banking_account.rb index ad1293a21..a94815d0e 100644 --- a/app/models/enable_banking_account.rb +++ b/app/models/enable_banking_account.rb @@ -16,6 +16,7 @@ class EnableBankingAccount < ApplicationRecord validates :name, :currency, presence: true validates :uid, presence: true, uniqueness: { scope: :enable_banking_item_id } + # account_id is not uniquely scoped: uid already enforces one-account-per-identifier per item # Helper to get account using account_providers system def current_account diff --git a/app/models/enable_banking_entry/processor.rb b/app/models/enable_banking_entry/processor.rb index 2429f27ce..643991491 100644 --- a/app/models/enable_banking_entry/processor.rb +++ b/app/models/enable_banking_entry/processor.rb @@ -6,7 +6,7 @@ class EnableBankingEntry::Processor # enable_banking_transaction is the raw hash fetched from Enable Banking API # Transaction structure from Enable Banking: # { - # transaction_id, entry_reference, booking_date, value_date, + # transaction_id, entry_reference, booking_date, value_date, transaction_date, # transaction_amount: { amount, currency }, # creditor_name, debtor_name, remittance_information, ... # } @@ -173,8 +173,8 @@ class EnableBankingEntry::Processor end def date - # Prefer booking_date, fall back to value_date - date_value = data[:booking_date] || data[:value_date] + # Prefer booking_date, fall back to value_date, then transaction_date + date_value = data[:booking_date] || data[:value_date] || data[:transaction_date] case date_value when String diff --git a/app/models/enable_banking_item/importer.rb b/app/models/enable_banking_item/importer.rb index 2e335c15c..9facd18d2 100644 --- a/app/models/enable_banking_item/importer.rb +++ b/app/models/enable_banking_item/importer.rb @@ -230,6 +230,11 @@ class EnableBankingItem::Importer break if continuation_key.blank? end + # Deduplicate API response: Enable Banking sometimes returns the same logical + # transaction with different entry_reference IDs in the same response. + # Remove content-level duplicates before storing. (Issue #954) + all_transactions = deduplicate_api_transactions(all_transactions) + transactions_count = all_transactions.count if all_transactions.any? @@ -259,6 +264,71 @@ class EnableBankingItem::Importer { success: false, transactions_count: 0, error: e.message } end + # Deduplicate transactions from the Enable Banking API response. + # Some banks return the same logical transaction multiple times with different + # entry_reference IDs. We build a composite content key that includes + # transaction_id (when present) alongside date, amount, currency, creditor, + # debtor, remittance_information, and status. Per the Enable Banking API docs + # transaction_id is not guaranteed to be unique, so it cannot be used as + # the sole dedup criterion. Including it in the composite key preserves + # legitimately distinct transactions with identical content but different + # transaction_ids (e.g. two laundromat payments on the same day). (Issue #954) + def deduplicate_api_transactions(transactions) + seen = {} + duplicates_removed = 0 + + result = transactions.select do |tx| + tx = tx.with_indifferent_access + key = build_transaction_content_key(tx) + + if seen[key] + duplicates_removed += 1 + false + else + seen[key] = true + true + end + end + + if duplicates_removed > 0 + Rails.logger.info( + "EnableBankingItem::Importer - Removed #{duplicates_removed} content-level " \ + "duplicate(s) from API response (#{transactions.count} → #{result.count} transactions)" + ) + end + + result + end + + # Build a composite key for deduplication. Two transactions with different + # entry_reference values but identical content fields (including + # transaction_id and credit_debit_indicator) are considered duplicates. + # transaction_id is included as one component — not a standalone key — + # because the Enable Banking API docs state it is not guaranteed to be + # unique. credit_debit_indicator (CRDT/DBIT) is included because + # transaction_amount.amount is always positive — without it, a payment + # and a same-day refund of the same amount would produce identical keys. + # Known limitation: when transaction_id is nil for both, pure content + # comparison applies. This means two genuinely distinct transactions + # with identical content (same date, amount, direction, creditor, etc.) + # and no transaction_id would collapse to one. In practice, banks that + # omit transaction_id rarely produce such exact duplicates in the same + # API response; timestamps or remittance info usually differ. (Issue #954) + def build_transaction_content_key(tx) + date = tx[:booking_date].presence || tx[:value_date] + amount = tx.dig(:transaction_amount, :amount).presence || tx[:amount] + currency = tx.dig(:transaction_amount, :currency).presence || tx[:currency] + creditor = tx.dig(:creditor, :name).presence || tx[:creditor_name] + debtor = tx.dig(:debtor, :name).presence || tx[:debtor_name] + remittance = tx[:remittance_information] + remittance_key = remittance.is_a?(Array) ? remittance.compact.map(&:to_s).sort.join("|") : remittance.to_s + status = tx[:status] + tid = tx[:transaction_id] + direction = tx[:credit_debit_indicator] + + [ date, amount, currency, creditor, debtor, remittance_key, status, tid, direction ].map(&:to_s).join("\x1F") + end + def determine_sync_start_date(enable_banking_account) has_stored_transactions = enable_banking_account.raw_transactions_payload.to_a.any? diff --git a/app/models/enable_banking_item/sync_complete_event.rb b/app/models/enable_banking_item/sync_complete_event.rb index 455ebccff..586241e9e 100644 --- a/app/models/enable_banking_item/sync_complete_event.rb +++ b/app/models/enable_banking_item/sync_complete_event.rb @@ -30,7 +30,7 @@ class EnableBankingItem::SyncCompleteEvent family, target: "enable_banking-providers-panel", partial: "settings/providers/enable_banking_panel", - locals: { enable_banking_items: enable_banking_items } + locals: { enable_banking_items: enable_banking_items, family: family } ) # Let family handle sync notifications diff --git a/app/models/enable_banking_item/syncer.rb b/app/models/enable_banking_item/syncer.rb index 627d03de6..dfe8ce984 100644 --- a/app/models/enable_banking_item/syncer.rb +++ b/app/models/enable_banking_item/syncer.rb @@ -1,4 +1,6 @@ class EnableBankingItem::Syncer + include SyncStats::Collector + attr_reader :enable_banking_item def initialize(enable_banking_item) @@ -30,17 +32,10 @@ class EnableBankingItem::Syncer # Phase 2: Check account setup status and collect sync statistics sync.update!(status_text: "Checking account configuration...") if sync.respond_to?(:status_text) - total_accounts = enable_banking_item.enable_banking_accounts.count + collect_setup_stats(sync, provider_accounts: enable_banking_item.enable_banking_accounts.includes(:account_provider, :account)) - linked_accounts = enable_banking_item.enable_banking_accounts.joins(:account_provider).joins(:account).merge(Account.visible) unlinked_accounts = enable_banking_item.enable_banking_accounts.left_joins(:account_provider).where(account_providers: { id: nil }) - sync_stats = { - total_accounts: total_accounts, - linked_accounts: linked_accounts.count, - unlinked_accounts: unlinked_accounts.count - } - if unlinked_accounts.any? enable_banking_item.update!(pending_account_setup: true) sync.update!(status_text: "#{unlinked_accounts.count} accounts need setup...") if sync.respond_to?(:status_text) @@ -48,11 +43,20 @@ class EnableBankingItem::Syncer enable_banking_item.update!(pending_account_setup: false) end - # Phase 3: Process transactions for linked accounts only - if linked_accounts.any? + # Phase 3: Process transactions for linked and visible accounts only + linked_account_ids = enable_banking_item.enable_banking_accounts + .joins(:account_provider) + .joins(:account) + .merge(Account.visible) + .pluck("accounts.id") + + if linked_account_ids.any? sync.update!(status_text: "Processing transactions...") if sync.respond_to?(:status_text) enable_banking_item.process_accounts + # Collect transaction statistics + collect_transaction_stats(sync, account_ids: linked_account_ids, source: "enable_banking") + # Phase 4: Schedule balance calculations for linked accounts sync.update!(status_text: "Calculating balances...") if sync.respond_to?(:status_text) enable_banking_item.schedule_account_syncs( @@ -62,9 +66,10 @@ class EnableBankingItem::Syncer ) end - if sync.respond_to?(:sync_stats) - sync.update!(sync_stats: sync_stats) - end + collect_health_stats(sync, errors: nil) + rescue => e + collect_health_stats(sync, errors: [ { message: e.message, category: "sync_error" } ]) + raise end def perform_post_sync diff --git a/app/models/entry.rb b/app/models/entry.rb index 3812e3d58..48b216f36 100644 --- a/app/models/entry.rb +++ b/app/models/entry.rb @@ -1,11 +1,16 @@ class Entry < ApplicationRecord include Monetizable, Enrichable + attr_accessor :unsplitting + monetize :amount belongs_to :account belongs_to :transfer, optional: true belongs_to :import, optional: true + belongs_to :parent_entry, class_name: "Entry", optional: true + + has_many :child_entries, class_name: "Entry", foreign_key: :parent_entry_id, dependent: :destroy delegated_type :entryable, types: Entryable::TYPES, dependent: :destroy accepts_nested_attributes_for :entryable @@ -15,6 +20,11 @@ class Entry < ApplicationRecord validates :date, comparison: { greater_than: -> { min_supported_date } } validates :external_id, uniqueness: { scope: [ :account_id, :source ] }, if: -> { external_id.present? && source.present? } + validate :cannot_unexclude_split_parent + validate :split_child_date_matches_parent + + before_destroy :prevent_individual_child_deletion, if: :split_child? + scope :visible, -> { joins(:account).where(accounts: { status: [ "draft", "active" ] }) } @@ -63,6 +73,14 @@ class Entry < ApplicationRecord SQL } + scope :excluding_split_parents, -> { + where(<<~SQL.squish) + NOT EXISTS ( + SELECT 1 FROM entries ce WHERE ce.parent_entry_id = entries.id + ) + SQL + } + # Find stale pending transactions (pending for more than X days with no matching posted version) scope :stale_pending, ->(days: 8) { pending.where("entries.date < ?", days.days.ago.to_date) @@ -73,6 +91,35 @@ class Entry < ApplicationRecord joins(:account).where(accounts: { family_id: family.id }) end + # Uncategorized, non-transfer transaction entries on draft or active accounts. + # Caller is responsible for scoping to accessible entries before applying this scope. + scope :uncategorized_transactions, -> { + joins(:account) + .joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'") + .where(accounts: { status: %w[draft active] }) + .where(transactions: { category_id: nil }) + .where.not(transactions: { kind: Transaction::TRANSFER_KINDS }) + .where(entries: { excluded: false }) + } + + # Returns uncategorized, non-transfer entries whose name matches the given filter string. + # Used by the Quick Categorize Wizard to preview which transactions a rule would affect. + # @param entries [ActiveRecord::Relation] pre-scoped entries (caller controls authorization) + def self.uncategorized_matching(entries, filter, transaction_type = nil) + sanitized = sanitize_sql_like(filter.gsub(/\s+/, " ").strip) + scope = entries + .uncategorized_transactions + .where("BTRIM(REGEXP_REPLACE(entries.name, '[[:space:]]+', ' ', 'g')) ILIKE ?", "%#{sanitized}%") + + scope = case transaction_type + when "income" then scope.where("entries.amount < 0") + when "expense" then scope.where("entries.amount >= 0") + else scope + end + + scope.includes(entryable: :merchant).order(entries: { date: :desc }).to_a + end + # Auto-exclude stale pending transactions for an account # Called during sync to clean up pending transactions that never posted # @param account [Account] The account to clean up @@ -313,6 +360,60 @@ class Entry < ApplicationRecord end end + def split_parent? + child_entries.exists? + end + + def split_child? + parent_entry_id.present? + end + + # Splits this entry into child entries. Marks parent as excluded. + # + # @param splits [Array] array of { name:, amount:, category_id: } hashes + # @return [Array] the created child entries + def split!(splits) + total = splits.sum { |s| s[:amount].to_d } + unless total == amount + raise ActiveRecord::RecordInvalid.new(self), "Split amounts must sum to parent amount (expected #{amount}, got #{total})" + end + + self.class.transaction do + children = splits.map do |split_attrs| + child_transaction = Transaction.new( + category_id: split_attrs[:category_id], + merchant_id: entryable.try(:merchant_id), + kind: entryable.try(:kind) + ) + + child_entries.create!( + account: account, + date: date, + name: split_attrs[:name], + amount: split_attrs[:amount], + currency: currency, + entryable: child_transaction + ) + end + + update!(excluded: true) + mark_user_modified! + + children + end + end + + # Removes split children and restores parent entry. + def unsplit! + self.class.transaction do + child_entries.each do |child| + child.unsplitting = true + child.destroy! + end + update!(excluded: false) + end + end + class << self def search(params) EntrySearch.new(params).build_query(all) @@ -352,10 +453,19 @@ class Entry < ApplicationRecord transaction do all.each do |entry| + changed = false + # Update standard attributes if bulk_attributes.present? - bulk_attributes[:entryable_attributes][:id] = entry.entryable_id if bulk_attributes[:entryable_attributes].present? - entry.update! bulk_attributes + attrs = bulk_attributes.dup + attrs.delete(:date) if entry.split_child? + + if attrs.present? + attrs[:entryable_attributes] = attrs[:entryable_attributes].dup if attrs[:entryable_attributes].present? + attrs[:entryable_attributes][:id] = entry.entryable_id if attrs[:entryable_attributes].present? + entry.update! attrs + changed = true + end end # Handle tags separately - only when explicitly requested @@ -363,14 +473,39 @@ class Entry < ApplicationRecord entry.transaction.tag_ids = tag_ids entry.transaction.save! entry.entryable.lock_attr!(:tag_ids) if entry.transaction.tags.any? + changed = true end - entry.lock_saved_attributes! - entry.mark_user_modified! + if changed + entry.lock_saved_attributes! + entry.mark_user_modified! + end end end all.size end end + + private + + def cannot_unexclude_split_parent + return unless excluded_changed?(from: true, to: false) && split_parent? + + errors.add(:excluded, "cannot be toggled off for a split transaction") + end + + def split_child_date_matches_parent + return unless split_child? && date_changed? + return unless parent_entry.present? + return if date == parent_entry.date + + errors.add(:date, "must match the parent transaction date for split children") + end + + def prevent_individual_child_deletion + return if destroyed_by_association || unsplitting + + throw :abort + end end diff --git a/app/models/entryable.rb b/app/models/entryable.rb index cf4d22821..d7507fe1b 100644 --- a/app/models/entryable.rb +++ b/app/models/entryable.rb @@ -10,7 +10,7 @@ module Entryable included do include Enrichable - has_one :entry, as: :entryable, touch: true + has_one :entry, as: :entryable, touch: true, dependent: :destroy scope :with_entry, -> { joins(:entry) } diff --git a/app/models/family.rb b/app/models/family.rb index 74f818ef6..a287c8957 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -1,7 +1,7 @@ class Family < ApplicationRecord include Syncable, AutoTransferMatchable, Subscribeable, VectorSearchable include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable - include CoinbaseConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable + include CoinbaseConnectable, BinanceConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable include IndexaCapitalConnectable DATE_FORMATS = [ @@ -20,6 +20,7 @@ class Family < ApplicationRecord MONIKERS = [ "Family", "Group" ].freeze ASSISTANT_TYPES = %w[builtin external].freeze + SHARING_DEFAULTS = %w[shared private].freeze has_many :users, dependent: :destroy has_many :accounts, dependent: :destroy @@ -50,6 +51,7 @@ class Family < ApplicationRecord validates :month_start_day, inclusion: { in: 1..28 } validates :moniker, inclusion: { in: MONIKERS } validates :assistant_type, inclusion: { in: ASSISTANT_TYPES } + validates :default_account_sharing, inclusion: { in: SHARING_DEFAULTS } def moniker_label @@ -60,6 +62,10 @@ class Family < ApplicationRecord moniker_label == "Group" ? "Groups" : "Families" end + def share_all_by_default? + default_account_sharing == "shared" + end + def uses_custom_month_start? month_start_day != 1 end @@ -100,6 +106,29 @@ class Family < ApplicationRecord Merchant.where(id: (assigned_ids + recently_unlinked_ids + family_merchant_ids).uniq) end + def assigned_merchants_for(user) + merchant_ids = Transaction.joins(:entry) + .where(entries: { account_id: accounts.accessible_by(user).select(:id) }) + .where.not(merchant_id: nil) + .distinct + .pluck(:merchant_id) + Merchant.where(id: merchant_ids) + end + + def available_merchants_for(user) + assigned_ids = Transaction.joins(:entry) + .where(entries: { account_id: accounts.accessible_by(user).select(:id) }) + .where.not(merchant_id: nil) + .distinct + .pluck(:merchant_id) + recently_unlinked_ids = FamilyMerchantAssociation + .where(family: self) + .recently_unlinked + .pluck(:merchant_id) + family_merchant_ids = merchants.pluck(:id) + Merchant.where(id: (assigned_ids + recently_unlinked_ids + family_merchant_ids).uniq) + end + def auto_categorize_transactions_later(transactions, rule_run_id: nil) AutoCategorizeJob.perform_later(self, transaction_ids: transactions.pluck(:id), rule_run_id: rule_run_id) end @@ -116,12 +145,12 @@ class Family < ApplicationRecord AutoMerchantDetector.new(self, transaction_ids: transaction_ids).auto_detect end - def balance_sheet - @balance_sheet ||= BalanceSheet.new(self) + def balance_sheet(user: Current.user) + BalanceSheet.new(self, user: user) end - def income_statement - @income_statement ||= IncomeStatement.new(self) + def income_statement(user: Current.user) + IncomeStatement.new(self, user: user) end # Returns the Investment Contributions category for this family, creating it if it doesn't exist. @@ -156,7 +185,6 @@ class Family < ApplicationRecord I18n.with_locale(locale) do categories.find_or_create_by!(name: Category.investment_contributions_name) do |cat| cat.color = "#0d9488" - cat.classification = "expense" cat.lucide_icon = "trending-up" end end @@ -192,8 +220,8 @@ class Family < ApplicationRecord end end - def investment_statement - @investment_statement ||= InvestmentStatement.new(self) + def investment_statement(user: Current.user) + InvestmentStatement.new(self, user: user) end def eu? diff --git a/app/models/family/auto_categorizer.rb b/app/models/family/auto_categorizer.rb index 1efb76c58..c3b452dbc 100644 --- a/app/models/family/auto_categorizer.rb +++ b/app/models/family/auto_categorizer.rb @@ -69,8 +69,7 @@ class Family::AutoCategorizer id: category.id, name: category.name, is_subcategory: category.subcategory?, - parent_id: category.parent_id, - classification: category.classification + parent_id: category.parent_id } end end diff --git a/app/models/family/binance_connectable.rb b/app/models/family/binance_connectable.rb new file mode 100644 index 000000000..c72bcf47e --- /dev/null +++ b/app/models/family/binance_connectable.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Family::BinanceConnectable + extend ActiveSupport::Concern + + included do + has_many :binance_items, dependent: :destroy + end + + def can_connect_binance? + true + end + + def create_binance_item!(api_key:, api_secret:, item_name: nil) + item = binance_items.create!( + name: item_name || "Binance", + api_key: api_key, + api_secret: api_secret + ) + item.sync_later + item + end + + def has_binance_credentials? + binance_items.where.not(api_key: nil).exists? + end +end diff --git a/app/models/family/data_exporter.rb b/app/models/family/data_exporter.rb index 6b3be40fd..dc0ffae13 100644 --- a/app/models/family/data_exporter.rb +++ b/app/models/family/data_exporter.rb @@ -65,8 +65,10 @@ class Family::DataExporter csv << [ "date", "account_name", "amount", "name", "category", "tags", "notes", "currency" ] # Only export transactions from accounts belonging to this family + # Exclude split parents (export children instead) @family.transactions .includes(:category, :tags, entry: :account) + .merge(Entry.excluding_split_parents) .find_each do |transaction| csv << [ transaction.entry.date.iso8601, @@ -105,7 +107,7 @@ class Family::DataExporter def generate_categories_csv CSV.generate do |csv| - csv << [ "name", "color", "parent_category", "classification", "lucide_icon" ] + csv << [ "name", "color", "parent_category", "lucide_icon" ] # Only export categories belonging to this family @family.categories.includes(:parent).find_each do |category| @@ -113,7 +115,6 @@ class Family::DataExporter category.name, category.color, category.parent&.name, - category.classification, category.lucide_icon ] end @@ -177,8 +178,8 @@ class Family::DataExporter }.to_json end - # Export transactions with full data - @family.transactions.includes(:category, :merchant, :tags, entry: :account).find_each do |transaction| + # Export transactions with full data (exclude split parents, export children instead) + @family.transactions.includes(:category, :merchant, :tags, entry: :account).merge(Entry.excluding_split_parents).find_each do |transaction| lines << { type: "Transaction", data: { diff --git a/app/models/family/data_importer.rb b/app/models/family/data_importer.rb new file mode 100644 index 000000000..4b6f2339b --- /dev/null +++ b/app/models/family/data_importer.rb @@ -0,0 +1,474 @@ +class Family::DataImporter + SUPPORTED_TYPES = %w[Account Category Tag Merchant Transaction Trade Valuation Budget BudgetCategory Rule].freeze + ACCOUNTABLE_TYPES = Accountable::TYPES.freeze + + def initialize(family, ndjson_content) + @family = family + @ndjson_content = ndjson_content + @id_mappings = { + accounts: {}, + categories: {}, + tags: {}, + merchants: {}, + budgets: {}, + securities: {} + } + @created_accounts = [] + @created_entries = [] + end + + def import! + records = parse_ndjson + + Import.transaction do + # Import in dependency order + import_accounts(records["Account"] || []) + import_categories(records["Category"] || []) + import_tags(records["Tag"] || []) + import_merchants(records["Merchant"] || []) + import_transactions(records["Transaction"] || []) + import_trades(records["Trade"] || []) + import_valuations(records["Valuation"] || []) + import_budgets(records["Budget"] || []) + import_budget_categories(records["BudgetCategory"] || []) + import_rules(records["Rule"] || []) + end + + { accounts: @created_accounts, entries: @created_entries } + end + + private + + def parse_ndjson + records = Hash.new { |h, k| h[k] = [] } + + @ndjson_content.each_line do |line| + next if line.strip.empty? + + begin + record = JSON.parse(line) + type = record["type"] + next unless SUPPORTED_TYPES.include?(type) + + records[type] << record + rescue JSON::ParserError + # Skip invalid lines + end + end + + records + end + + def import_accounts(records) + records.each do |record| + data = record["data"] + old_id = data["id"] + accountable_data = data["accountable"] || {} + accountable_type = data["accountable_type"] + + # Skip if accountable type is not valid + next unless ACCOUNTABLE_TYPES.include?(accountable_type) + + # Build accountable + accountable_class = accountable_type.constantize + accountable = accountable_class.new + accountable.subtype = accountable_data["subtype"] if accountable.respond_to?(:subtype=) && accountable_data["subtype"] + + # Copy any other accountable attributes + safe_accountable_attrs = %w[subtype locked_attributes] + safe_accountable_attrs.each do |attr| + if accountable.respond_to?("#{attr}=") && accountable_data[attr].present? + accountable.send("#{attr}=", accountable_data[attr]) + end + end + + account = @family.accounts.build( + name: data["name"], + balance: data["balance"].to_d, + cash_balance: data["cash_balance"]&.to_d || data["balance"].to_d, + currency: data["currency"] || @family.currency, + accountable: accountable, + subtype: data["subtype"], + institution_name: data["institution_name"], + institution_domain: data["institution_domain"], + notes: data["notes"], + status: "active" + ) + + account.save! + + # Set opening balance if we have a historical balance + if data["balance"].present? + manager = Account::OpeningBalanceManager.new(account) + manager.set_opening_balance(balance: data["balance"].to_d) + end + + @id_mappings[:accounts][old_id] = account.id + @created_accounts << account + end + end + + def import_categories(records) + # First pass: create all categories without parent relationships + parent_mappings = {} + + records.each do |record| + data = record["data"] + old_id = data["id"] + parent_id = data["parent_id"] + + # Store parent relationship for second pass + parent_mappings[old_id] = parent_id if parent_id.present? + + category = @family.categories.build( + name: data["name"], + color: data["color"] || Category::UNCATEGORIZED_COLOR, + classification_unused: data["classification_unused"] || data["classification"] || "expense", + lucide_icon: data["lucide_icon"] || "shapes" + ) + + category.save! + @id_mappings[:categories][old_id] = category.id + end + + # Second pass: establish parent relationships + parent_mappings.each do |old_id, old_parent_id| + new_id = @id_mappings[:categories][old_id] + new_parent_id = @id_mappings[:categories][old_parent_id] + + next unless new_id && new_parent_id + + category = @family.categories.find(new_id) + category.update!(parent_id: new_parent_id) + end + end + + def import_tags(records) + records.each do |record| + data = record["data"] + old_id = data["id"] + + tag = @family.tags.build( + name: data["name"], + color: data["color"] || Tag::COLORS.sample + ) + + tag.save! + @id_mappings[:tags][old_id] = tag.id + end + end + + def import_merchants(records) + records.each do |record| + data = record["data"] + old_id = data["id"] + + merchant = @family.merchants.build( + name: data["name"], + color: data["color"], + logo_url: data["logo_url"] + ) + + merchant.save! + @id_mappings[:merchants][old_id] = merchant.id + end + end + + def import_transactions(records) + records.each do |record| + data = record["data"] + + # Map account ID + new_account_id = @id_mappings[:accounts][data["account_id"]] + next unless new_account_id + + account = @family.accounts.find(new_account_id) + + # Map category ID (optional) + new_category_id = nil + if data["category_id"].present? + new_category_id = @id_mappings[:categories][data["category_id"]] + end + + # Map merchant ID (optional) + new_merchant_id = nil + if data["merchant_id"].present? + new_merchant_id = @id_mappings[:merchants][data["merchant_id"]] + end + + # Map tag IDs (optional) + new_tag_ids = [] + if data["tag_ids"].present? + new_tag_ids = Array(data["tag_ids"]).map { |old_tag_id| @id_mappings[:tags][old_tag_id] }.compact + end + + transaction = Transaction.new( + category_id: new_category_id, + merchant_id: new_merchant_id, + kind: data["kind"] || "standard" + ) + + entry = Entry.new( + account: account, + date: Date.parse(data["date"].to_s), + amount: data["amount"].to_d, + name: data["name"] || "Imported transaction", + currency: data["currency"] || account.currency, + notes: data["notes"], + excluded: data["excluded"] || false, + entryable: transaction + ) + + entry.save! + + # Add tags through the tagging association + new_tag_ids.each do |tag_id| + transaction.taggings.create!(tag_id: tag_id) + end + + @created_entries << entry + end + end + + def import_trades(records) + records.each do |record| + data = record["data"] + + # Map account ID + new_account_id = @id_mappings[:accounts][data["account_id"]] + next unless new_account_id + + account = @family.accounts.find(new_account_id) + + # Resolve or create security + ticker = data["ticker"] + next unless ticker.present? + + security = find_or_create_security(ticker, data["currency"]) + + trade = Trade.new( + security: security, + qty: data["qty"].to_d, + price: data["price"].to_d, + currency: data["currency"] || account.currency + ) + + entry = Entry.new( + account: account, + date: Date.parse(data["date"].to_s), + amount: data["amount"].to_d, + name: "#{data["qty"].to_d >= 0 ? 'Buy' : 'Sell'} #{ticker}", + currency: data["currency"] || account.currency, + entryable: trade + ) + + entry.save! + @created_entries << entry + end + end + + def import_valuations(records) + records.each do |record| + data = record["data"] + + # Map account ID + new_account_id = @id_mappings[:accounts][data["account_id"]] + next unless new_account_id + + account = @family.accounts.find(new_account_id) + + valuation = Valuation.new + + entry = Entry.new( + account: account, + date: Date.parse(data["date"].to_s), + amount: data["amount"].to_d, + name: data["name"] || "Valuation", + currency: data["currency"] || account.currency, + entryable: valuation + ) + + entry.save! + @created_entries << entry + end + end + + def import_budgets(records) + records.each do |record| + data = record["data"] + old_id = data["id"] + + budget = @family.budgets.build( + start_date: Date.parse(data["start_date"].to_s), + end_date: Date.parse(data["end_date"].to_s), + budgeted_spending: data["budgeted_spending"]&.to_d, + expected_income: data["expected_income"]&.to_d, + currency: data["currency"] || @family.currency + ) + + budget.save! + @id_mappings[:budgets][old_id] = budget.id + end + end + + def import_budget_categories(records) + records.each do |record| + data = record["data"] + + # Map budget ID + new_budget_id = @id_mappings[:budgets][data["budget_id"]] + next unless new_budget_id + + # Map category ID + new_category_id = @id_mappings[:categories][data["category_id"]] + next unless new_category_id + + budget = @family.budgets.find(new_budget_id) + + budget_category = budget.budget_categories.build( + category_id: new_category_id, + budgeted_spending: data["budgeted_spending"].to_d, + currency: data["currency"] || budget.currency + ) + + budget_category.save! + end + end + + def import_rules(records) + records.each do |record| + data = record["data"] + + rule = @family.rules.build( + name: data["name"], + resource_type: data["resource_type"] || "transaction", + active: data["active"] || false, + effective_date: data["effective_date"].present? ? Date.parse(data["effective_date"].to_s) : nil + ) + + # Build conditions + (data["conditions"] || []).each do |condition_data| + build_rule_condition(rule, condition_data) + end + + # Build actions + (data["actions"] || []).each do |action_data| + build_rule_action(rule, action_data) + end + + rule.save! + end + end + + def build_rule_condition(rule, condition_data, parent: nil) + value = resolve_rule_condition_value(condition_data) + + condition = if parent + parent.sub_conditions.build( + condition_type: condition_data["condition_type"], + operator: condition_data["operator"], + value: value + ) + else + rule.conditions.build( + condition_type: condition_data["condition_type"], + operator: condition_data["operator"], + value: value + ) + end + + # Handle nested sub_conditions for compound conditions + (condition_data["sub_conditions"] || []).each do |sub_condition_data| + build_rule_condition(rule, sub_condition_data, parent: condition) + end + + condition + end + + def build_rule_action(rule, action_data) + value = resolve_rule_action_value(action_data) + + rule.actions.build( + action_type: action_data["action_type"], + value: value + ) + end + + def resolve_rule_condition_value(condition_data) + condition_type = condition_data["condition_type"] + value = condition_data["value"] + + return value unless value.present? + + # Map category names to IDs + if condition_type == "transaction_category" + category = @family.categories.find_by(name: value) + category ||= @family.categories.create!( + name: value, + color: Category::UNCATEGORIZED_COLOR, + classification_unused: "expense", + lucide_icon: "shapes" + ) + return category.id + end + + # Map merchant names to IDs + if condition_type == "transaction_merchant" + merchant = @family.merchants.find_by(name: value) + merchant ||= @family.merchants.create!(name: value) + return merchant.id + end + + value + end + + def resolve_rule_action_value(action_data) + action_type = action_data["action_type"] + value = action_data["value"] + + return value unless value.present? + + # Map category names to IDs + if action_type == "set_transaction_category" + category = @family.categories.find_by(name: value) + category ||= @family.categories.create!( + name: value, + color: Category::UNCATEGORIZED_COLOR, + classification_unused: "expense", + lucide_icon: "shapes" + ) + return category.id + end + + # Map merchant names to IDs + if action_type == "set_transaction_merchant" + merchant = @family.merchants.find_by(name: value) + merchant ||= @family.merchants.create!(name: value) + return merchant.id + end + + # Map tag names to IDs + if action_type == "set_transaction_tags" + tag = @family.tags.find_by(name: value) + tag ||= @family.tags.create!(name: value) + return tag.id + end + + value + end + + def find_or_create_security(ticker, currency) + # Check cache first + cache_key = "#{ticker}:#{currency}" + return @id_mappings[:securities][cache_key] if @id_mappings[:securities][cache_key] + + security = Security.find_by(ticker: ticker.upcase) + security ||= Security.create!( + ticker: ticker.upcase, + name: ticker.upcase + ) + + @id_mappings[:securities][cache_key] = security + security + end +end diff --git a/app/models/family/subscribeable.rb b/app/models/family/subscribeable.rb index de75bbe0c..9ac267f6c 100644 --- a/app/models/family/subscribeable.rb +++ b/app/models/family/subscribeable.rb @@ -1,8 +1,27 @@ module Family::Subscribeable extend ActiveSupport::Concern + CLEANUP_GRACE_PERIOD = 14.days + ARCHIVE_TRANSACTION_THRESHOLD = 12 + ARCHIVE_RECENT_ACTIVITY_WINDOW = 14.days + included do has_one :subscription, dependent: :destroy + + scope :inactive_trial_for_cleanup, -> { + cutoff_with_sub = CLEANUP_GRACE_PERIOD.ago + cutoff_without_sub = (Subscription::TRIAL_DAYS.days + CLEANUP_GRACE_PERIOD).ago + + expired_trial = left_joins(:subscription) + .where(subscriptions: { status: [ "paused", "trialing" ] }) + .where(subscriptions: { trial_ends_at: ...cutoff_with_sub }) + + no_subscription = left_joins(:subscription) + .where(subscriptions: { id: nil }) + .where(families: { created_at: ...cutoff_without_sub }) + + where(id: expired_trial).or(where(id: no_subscription)) + } end def payment_email @@ -85,4 +104,13 @@ module Family::Subscribeable subscription.update!(status: "paused") end end + + def requires_data_archive? + return false unless transactions.count > ARCHIVE_TRANSACTION_THRESHOLD + + trial_end = subscription&.trial_ends_at || (created_at + Subscription::TRIAL_DAYS.days) + recent_window_start = trial_end - ARCHIVE_RECENT_ACTIVITY_WINDOW + + entries.where(date: recent_window_start..trial_end).exists? + end end diff --git a/app/models/family/sync_complete_event.rb b/app/models/family/sync_complete_event.rb index 925384605..ef10e14dc 100644 --- a/app/models/family/sync_complete_event.rb +++ b/app/models/family/sync_complete_event.rb @@ -6,28 +6,11 @@ class Family::SyncCompleteEvent end def broadcast - # Dashboard partials can occasionally raise when rendered from background jobs - # (e.g., if intermediate series values are nil during a sync). Make broadcasts - # resilient so a post-sync UI refresh never causes the overall sync to report an error. - begin - family.broadcast_replace( - target: "balance-sheet", - partial: "pages/dashboard/balance_sheet", - locals: { balance_sheet: family.balance_sheet } - ) - rescue => e - Rails.logger.error("Family::SyncCompleteEvent balance_sheet broadcast failed: #{e.message}\n#{e.backtrace&.join("\n")}") - end - - begin - family.broadcast_replace( - target: "net-worth-chart", - partial: "pages/dashboard/net_worth_chart", - locals: { balance_sheet: family.balance_sheet, period: Period.last_30_days } - ) - rescue => e - Rails.logger.error("Family::SyncCompleteEvent net_worth_chart broadcast failed: #{e.message}\n#{e.backtrace&.join("\n")}") - end + # Broadcast a refresh signal instead of rendered HTML. Each user's browser + # re-fetches via their own authenticated request, so the balance sheet and + # net worth chart are correctly scoped to the current user (Current.user is + # nil in background jobs, which would produce an unscoped family-wide view). + family.broadcast_refresh # Schedule recurring transaction pattern identification (debounced to run after all syncs complete) begin diff --git a/app/models/holding/forward_calculator.rb b/app/models/holding/forward_calculator.rb index ce490acba..ecb59e826 100644 --- a/app/models/holding/forward_calculator.rb +++ b/app/models/holding/forward_calculator.rb @@ -1,8 +1,9 @@ class Holding::ForwardCalculator attr_reader :account - def initialize(account) + def initialize(account, security_ids: nil) @account = account + @security_ids = security_ids # Track cost basis per security: { security_id => { total_cost: BigDecimal, total_qty: BigDecimal } } @cost_basis_tracker = Hash.new { |h, k| h[k] = { total_cost: BigDecimal("0"), total_qty: BigDecimal("0") } } end @@ -27,7 +28,7 @@ class Holding::ForwardCalculator private def portfolio_cache - @portfolio_cache ||= Holding::PortfolioCache.new(account) + @portfolio_cache ||= Holding::PortfolioCache.new(account, security_ids: @security_ids) end def empty_portfolio @@ -55,6 +56,8 @@ class Holding::ForwardCalculator def build_holdings(portfolio, date, price_source: nil) portfolio.map do |security_id, qty| + next if @security_ids && !@security_ids.include?(security_id) + price = portfolio_cache.get_price(security_id, date, source: price_source) if price.nil? diff --git a/app/models/holding/materializer.rb b/app/models/holding/materializer.rb index 7359a2099..e4ad1737c 100644 --- a/app/models/holding/materializer.rb +++ b/app/models/holding/materializer.rb @@ -1,9 +1,10 @@ # "Materializes" holdings (similar to a DB materialized view, but done at the app level) # into a series of records we can easily query and join with other data. class Holding::Materializer - def initialize(account, strategy:) + def initialize(account, strategy:, security_ids: nil) @account = account @strategy = strategy + @security_ids = security_ids end def materialize_holdings @@ -12,13 +13,20 @@ class Holding::Materializer Rails.logger.info("Persisting #{@holdings.size} holdings") persist_holdings - if strategy == :forward + if strategy == :forward && security_ids.nil? purge_stale_holdings end - # Clean up calculated holdings for securities that now have provider-sourced holdings - # This prevents duplicates when a manually-entered account gets linked to a provider - cleanup_calculated_holdings_for_provider_securities + # Clean up only calculated holdings that are directly shadowed by a provider snapshot + # on the same date/security/currency. Historical calculated rows for provider-linked + # securities are still needed to derive sane balance charts between sync snapshots. + cleanup_shadowed_calculated_holdings + + # Also remove calculated rows on the provider's latest snapshot date when those + # securities are no longer present in the provider payload. This keeps "current" + # holdings/balance composition aligned with the provider snapshot while preserving + # older calculated history. + cleanup_stale_calculated_rows_on_latest_provider_snapshot # Reload holdings association to clear any cached stale data # This ensures subsequent Balance calculations see the fresh holdings @@ -28,7 +36,7 @@ class Holding::Materializer end private - attr_reader :account, :strategy + attr_reader :account, :strategy, :security_ids def calculate_holdings @holdings = calculator.calculate @@ -47,9 +55,6 @@ class Holding::Materializer holdings_to_upsert_without_cost = [] @holdings.each do |holding| - # Skip securities that have provider-sourced holdings - don't overwrite provider data - next if provider_sourced_security_ids.include?(holding.security_id) - key = holding_key(holding) existing = existing_holdings_map[key] @@ -117,27 +122,48 @@ class Holding::Materializer .index_by { |h| holding_key(h) } end - # Get security IDs that have provider-sourced holdings (any date) - # These should be preserved and not overwritten by calculated holdings - def provider_sourced_security_ids - @provider_sourced_security_ids ||= account.holdings - .where.not(account_provider_id: nil) - .distinct - .pluck(:security_id) - end - - # Remove calculated holdings (account_provider_id IS NULL) for securities - # that now have provider-sourced holdings. This prevents duplicates when - # a manually-entered account gets linked to a provider. - def cleanup_calculated_holdings_for_provider_securities - return if provider_sourced_security_ids.empty? - + # Remove only calculated holdings that collide with an authoritative provider snapshot + # on the exact same key. This preserves reverse-calculated history for linked accounts. + def cleanup_shadowed_calculated_holdings deleted_count = account.holdings .where(account_provider_id: nil) - .where(security_id: provider_sourced_security_ids) + .where(<<~SQL) + EXISTS ( + SELECT 1 + FROM holdings provider_holdings + WHERE provider_holdings.account_id = holdings.account_id + AND provider_holdings.security_id = holdings.security_id + AND provider_holdings.date = holdings.date + AND provider_holdings.currency = holdings.currency + AND provider_holdings.account_provider_id IS NOT NULL + ) + SQL .delete_all - Rails.logger.info("Cleaned up #{deleted_count} calculated holdings for provider-sourced securities") if deleted_count > 0 + Rails.logger.info("Cleaned up #{deleted_count} calculated holdings shadowed by provider snapshots") if deleted_count > 0 + end + + def cleanup_stale_calculated_rows_on_latest_provider_snapshot + provider_snapshot_date = account.latest_provider_holdings_snapshot_date + return unless provider_snapshot_date + + provider_security_ids = account.holdings + .where.not(account_provider_id: nil) + .where(date: provider_snapshot_date) + .distinct + .pluck(:security_id) + + scope = account.holdings + .where(account_provider_id: nil, date: provider_snapshot_date) + + scope = if provider_security_ids.any? + scope.where.not(security_id: provider_security_ids) + else + scope + end + + deleted_count = scope.delete_all + Rails.logger.info("Cleaned up #{deleted_count} stale calculated holdings on latest provider snapshot date") if deleted_count > 0 end def holding_key(holding) @@ -164,9 +190,9 @@ class Holding::Materializer def calculator if strategy == :reverse portfolio_snapshot = Holding::PortfolioSnapshot.new(account) - Holding::ReverseCalculator.new(account, portfolio_snapshot: portfolio_snapshot) + Holding::ReverseCalculator.new(account, portfolio_snapshot: portfolio_snapshot, security_ids: security_ids) else - Holding::ForwardCalculator.new(account) + Holding::ForwardCalculator.new(account, security_ids: security_ids) end end end diff --git a/app/models/holding/portfolio_cache.rb b/app/models/holding/portfolio_cache.rb index 9ffed15b4..6763d1fd1 100644 --- a/app/models/holding/portfolio_cache.rb +++ b/app/models/holding/portfolio_cache.rb @@ -7,9 +7,10 @@ class Holding::PortfolioCache end end - def initialize(account, use_holdings: false) + def initialize(account, use_holdings: false, security_ids: nil) @account = account @use_holdings = use_holdings + @security_ids = security_ids load_prices end @@ -62,10 +63,12 @@ class Holding::PortfolioCache def collect_unique_securities unique_securities_from_trades = trades.map(&:entryable).map(&:security).uniq + unique_securities_from_trades = unique_securities_from_trades.select { |s| @security_ids.include?(s.id) } if @security_ids return unique_securities_from_trades unless use_holdings unique_securities_from_holdings = holdings.map(&:security).uniq + unique_securities_from_holdings = unique_securities_from_holdings.select { |s| @security_ids.include?(s.id) } if @security_ids (unique_securities_from_trades + unique_securities_from_holdings).uniq end diff --git a/app/models/holding/portfolio_snapshot.rb b/app/models/holding/portfolio_snapshot.rb index 0c512873c..2bf140efa 100644 --- a/app/models/holding/portfolio_snapshot.rb +++ b/app/models/holding/portfolio_snapshot.rb @@ -21,12 +21,22 @@ class Holding::PortfolioSnapshot .uniq .each_with_object({}) { |security_id, hash| hash[security_id] = 0 } - # Get the most recent holding for each security and update quantities - account.holdings - .select("DISTINCT ON (security_id) security_id, qty") - .order(:security_id, date: :desc) - .each { |holding| portfolio[holding.security_id] = holding.qty } + latest_holdings_scope.each do |holding| + portfolio[holding.security_id] = holding.qty + end portfolio end + + def latest_holdings_scope + if (provider_snapshot_date = account.latest_provider_holdings_snapshot_date) + account.holdings + .where.not(account_provider_id: nil) + .where(date: provider_snapshot_date) + else + account.holdings + .select("DISTINCT ON (security_id) holdings.*") + .order(:security_id, date: :desc) + end + end end diff --git a/app/models/holding/reverse_calculator.rb b/app/models/holding/reverse_calculator.rb index 2a4ea0375..d9ed2efe0 100644 --- a/app/models/holding/reverse_calculator.rb +++ b/app/models/holding/reverse_calculator.rb @@ -1,9 +1,10 @@ class Holding::ReverseCalculator attr_reader :account, :portfolio_snapshot - def initialize(account, portfolio_snapshot:) + def initialize(account, portfolio_snapshot:, security_ids: nil) @account = account @portfolio_snapshot = portfolio_snapshot + @security_ids = security_ids end def calculate @@ -19,7 +20,7 @@ class Holding::ReverseCalculator # since it is common for a provider to supply "current day" holdings but not all the historical # trades that make up those holdings. def portfolio_cache - @portfolio_cache ||= Holding::PortfolioCache.new(account, use_holdings: true) + @portfolio_cache ||= Holding::PortfolioCache.new(account, use_holdings: true, security_ids: @security_ids) end def calculate_holdings @@ -57,6 +58,8 @@ class Holding::ReverseCalculator def build_holdings(portfolio, date, price_source: nil) portfolio.map do |security_id, qty| + next if @security_ids && !@security_ids.include?(security_id) + price = portfolio_cache.get_price(security_id, date, source: price_source) if price.nil? diff --git a/app/models/import.rb b/app/models/import.rb index 203ed3a1a..941a6ead6 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -9,7 +9,7 @@ class Import < ApplicationRecord DOCUMENT_TYPES = %w[bank_statement credit_card_statement investment_statement financial_document contract other].freeze - TYPES = %w[TransactionImport TradeImport AccountImport MintImport CategoryImport RuleImport PdfImport].freeze + TYPES = %w[TransactionImport TradeImport AccountImport MintImport CategoryImport RuleImport PdfImport QifImport SureImport].freeze SIGNAGE_CONVENTIONS = %w[inflows_positive inflows_negative] SEPARATORS = [ [ "Comma (,)", "," ], [ "Semicolon (;)", ";" ] ].freeze @@ -20,6 +20,10 @@ class Import < ApplicationRecord "1,234" => { separator: "", delimiter: "," } # Zero-decimal currencies like JPY }.freeze + def self.reasonable_date_range + Date.new(1970, 1, 1)..Date.today.next_year(5) + end + AMOUNT_TYPE_STRATEGIES = %w[signed_amount custom_column].freeze belongs_to :family @@ -64,6 +68,51 @@ class Import < ApplicationRecord liberal_parsing: true ) end + + # Attempts to identify the best-matching date format from a list of candidates + # by trying to parse sample date strings with each format. + # + # Returns the strptime format string (e.g. "%m-%d-%Y") that best matches the + # samples, or the +fallback+ when no candidate can parse any sample. + # + # Scoring: + # 1. Formats that parse ALL samples beat those that only parse some. + # 2. Among equal parse counts, formats whose parsed dates fall within a + # reasonable range (1970..today+5y) score higher. + def detect_date_format(samples, candidates: Family::DATE_FORMATS.map(&:last), fallback: "%Y-%m-%d") + return fallback if samples.blank? + + cleaned = samples.map(&:to_s).reject(&:blank?).uniq.first(50) + return fallback if cleaned.empty? + + reasonable_range = reasonable_date_range + + scored = candidates.map do |fmt| + parsed_count = 0 + reasonable_count = 0 + + cleaned.each do |s| + begin + date = Date.strptime(s, fmt) + rescue Date::Error, ArgumentError + next + end + next unless date + + parsed_count += 1 + reasonable_count += 1 if reasonable_range.cover?(date) + end + + { format: fmt, parsed: parsed_count, reasonable: reasonable_count } + end + + # Filter to candidates that parsed at least one sample + viable = scored.select { |s| s[:parsed] > 0 } + return fallback if viable.empty? + + best = viable.max_by { |s| [ s[:parsed], s[:reasonable] ] } + best[:format] + end end def publish_later @@ -197,6 +246,10 @@ class Import < ApplicationRecord [] end + def rows_ordered + rows.ordered + end + def uploaded? raw_file_str.present? end @@ -248,6 +301,38 @@ class Import < ApplicationRecord ) end + # Returns date formats that can successfully parse the file's date samples, + # filtered to dates within reasonable_date_range. + # Result: array of { label:, format:, preview: } hashes. + # Subclasses should override #raw_date_samples to provide date strings. + def valid_date_formats_with_preview + first_sample = raw_date_samples.find(&:present?) + return [] if first_sample.blank? + + Family::DATE_FORMATS.filter_map do |label, fmt| + parsed = try_parse_date_sample(first_sample, format: fmt) + next unless parsed + next unless self.class.reasonable_date_range.cover?(Date.parse(parsed)) + + { label: label, format: fmt, preview: parsed } + end + end + + # Returns raw date strings from the import file for format detection/preview. + # Subclasses should override to extract dates from their specific format. + def raw_date_samples + [] + end + + # Attempts to parse a raw date sample with the given strptime format. + # Returns ISO 8601 date string or nil. Subclasses can override for + # format-specific normalization (e.g. QIF apostrophe dates). + def try_parse_date_sample(sample, format:) + Date.strptime(sample, format).iso8601 + rescue Date::Error, ArgumentError + nil + end + def max_row_count 10000 end diff --git a/app/models/import/category_mapping.rb b/app/models/import/category_mapping.rb index 4b633ea47..56e7fbdd4 100644 --- a/app/models/import/category_mapping.rb +++ b/app/models/import/category_mapping.rb @@ -2,10 +2,26 @@ class Import::CategoryMapping < Import::Mapping class << self def mappables_by_key(import) unique_values = import.rows.map(&:category).uniq - categories = import.family.categories.where(name: unique_values).index_by(&:name) - unique_values.index_with { |value| categories[value] } + # For hierarchical QIF keys like "Home:Home Improvement", look up the child + # name ("Home Improvement") since category names are unique per family. + lookup_names = unique_values.map { |v| leaf_category_name(v) } + categories = import.family.categories.where(name: lookup_names).index_by(&:name) + + unique_values.index_with { |value| categories[leaf_category_name(value)] } end + + private + + # Returns the leaf (child) name for a potentially hierarchical key. + # "Home:Home Improvement" → "Home Improvement" + # "Fees & Charges" → "Fees & Charges" + def leaf_category_name(key) + return "" if key.blank? + + parts = key.to_s.split(":", 2) + parts.length == 2 ? parts[1].strip : key + end end def selectable_values @@ -33,7 +49,30 @@ class Import::CategoryMapping < Import::Mapping def create_mappable! return unless creatable? - self.mappable = import.family.categories.find_or_create_by!(name: key) + parts = key.split(":", 2) + + if parts.length == 2 + parent_name = parts[0].strip + child_name = parts[1].strip + + # Ensure the parent category exists before creating the child. + parent = import.family.categories.find_or_create_by!(name: parent_name) do |cat| + cat.color = Category::COLORS.sample + cat.lucide_icon = Category.suggested_icon(parent_name) + end + + self.mappable = import.family.categories.find_or_create_by!(name: child_name) do |cat| + cat.parent = parent + cat.color = parent.color + cat.lucide_icon = Category.suggested_icon(child_name) + end + else + self.mappable = import.family.categories.find_or_create_by!(name: key) do |cat| + cat.color = Category::COLORS.sample + cat.lucide_icon = Category.suggested_icon(key) + end + end + save! end end diff --git a/app/models/income_statement.rb b/app/models/income_statement.rb index 83aa2c9fd..f885e3f81 100644 --- a/app/models/income_statement.rb +++ b/app/models/income_statement.rb @@ -5,10 +5,11 @@ class IncomeStatement monetize :median_expense, :median_income - attr_reader :family + attr_reader :family, :user - def initialize(family) + def initialize(family, user: nil) @family = family + @user = user || Current.user end def totals(transactions_scope: nil, date_range:) @@ -36,6 +37,65 @@ class IncomeStatement build_period_total(classification: "income", period: period) end + def net_category_totals(period: Period.current_month) + expense = expense_totals(period: period) + income = income_totals(period: period) + + # Use a stable key for each category: id for persisted, invariant token for synthetic + cat_key = ->(ct) { + if ct.category.uncategorized? + :uncategorized + elsif ct.category.other_investments? + :other_investments + else + ct.category.id + end + } + + expense_by_cat = expense.category_totals.reject { |ct| ct.category.subcategory? }.index_by { |ct| cat_key.call(ct) } + income_by_cat = income.category_totals.reject { |ct| ct.category.subcategory? }.index_by { |ct| cat_key.call(ct) } + + all_keys = (expense_by_cat.keys + income_by_cat.keys).uniq + raw_expense_categories = [] + raw_income_categories = [] + + all_keys.each do |key| + exp_ct = expense_by_cat[key] + inc_ct = income_by_cat[key] + exp_total = exp_ct&.total || 0 + inc_total = inc_ct&.total || 0 + net = exp_total - inc_total + category = exp_ct&.category || inc_ct&.category + + if net > 0 + raw_expense_categories << { category: category, total: net } + elsif net < 0 + raw_income_categories << { category: category, total: net.abs } + end + end + + total_net_expense = raw_expense_categories.sum { |r| r[:total] } + total_net_income = raw_income_categories.sum { |r| r[:total] } + + net_expense_categories = raw_expense_categories.map do |r| + weight = total_net_expense.zero? ? 0 : (r[:total].to_f / total_net_expense) * 100 + CategoryTotal.new(category: r[:category], total: r[:total], currency: family.currency, weight: weight) + end + + net_income_categories = raw_income_categories.map do |r| + weight = total_net_income.zero? ? 0 : (r[:total].to_f / total_net_income) * 100 + CategoryTotal.new(category: r[:category], total: r[:total], currency: family.currency, weight: weight) + end + + NetCategoryTotals.new( + net_expense_categories: net_expense_categories, + net_income_categories: net_income_categories, + total_net_expense: total_net_expense, + total_net_income: total_net_income, + currency: family.currency + ) + end + def median_expense(interval: "month", category: nil) if category.present? category_stats(interval: interval).find { |stat| stat.classification == "expense" && stat.category_id == category.id }&.median || 0 @@ -60,6 +120,7 @@ class IncomeStatement ScopeTotals = Data.define(:transactions_count, :income_money, :expense_money) PeriodTotal = Data.define(:classification, :total, :currency, :category_totals) CategoryTotal = Data.define(:category, :total, :currency, :weight) + NetCategoryTotals = Data.define(:net_expense_categories, :net_income_categories, :total_net_expense, :total_net_income, :currency) def categories @categories ||= family.categories.all.to_a @@ -115,23 +176,31 @@ class IncomeStatement def family_stats(interval: "month") @family_stats ||= {} @family_stats[interval] ||= Rails.cache.fetch([ - "income_statement", "family_stats", family.id, interval, family.entries_cache_version - ]) { FamilyStats.new(family, interval:).call } + "income_statement", "family_stats", family.id, user&.id, interval, included_account_ids_hash, family.entries_cache_version + ]) { FamilyStats.new(family, interval:, account_ids: included_account_ids).call } end def category_stats(interval: "month") @category_stats ||= {} @category_stats[interval] ||= Rails.cache.fetch([ - "income_statement", "category_stats", family.id, interval, family.entries_cache_version - ]) { CategoryStats.new(family, interval:).call } + "income_statement", "category_stats", family.id, user&.id, interval, included_account_ids_hash, family.entries_cache_version + ]) { CategoryStats.new(family, interval:, account_ids: included_account_ids).call } + end + + def included_account_ids + @included_account_ids ||= user ? user.finance_accounts.pluck(:id) : nil + end + + def included_account_ids_hash + @included_account_ids_hash ||= included_account_ids ? Digest::MD5.hexdigest(included_account_ids.sort.join(",")) : nil end def totals_query(transactions_scope:, date_range:) sql_hash = Digest::MD5.hexdigest(transactions_scope.to_sql) Rails.cache.fetch([ - "income_statement", "totals_query", "v2", family.id, sql_hash, family.entries_cache_version - ]) { Totals.new(family, transactions_scope: transactions_scope, date_range: date_range).call } + "income_statement", "totals_query", "v2", family.id, user&.id, included_account_ids_hash, sql_hash, date_range.begin, date_range.end, family.entries_cache_version + ]) { Totals.new(family, transactions_scope: transactions_scope, date_range: date_range, included_account_ids: included_account_ids).call } end def monetizable_currency diff --git a/app/models/income_statement/category_stats.rb b/app/models/income_statement/category_stats.rb index 0476549e4..f9757da16 100644 --- a/app/models/income_statement/category_stats.rb +++ b/app/models/income_statement/category_stats.rb @@ -1,10 +1,13 @@ class IncomeStatement::CategoryStats - def initialize(family, interval: "month") + def initialize(family, interval: "month", account_ids: nil) @family = family @interval = interval + @account_ids = account_ids end def call + return [] if @account_ids&.empty? + ActiveRecord::Base.connection.select_all(sanitized_query_sql).map do |row| StatRow.new( category_id: row["category_id"], @@ -48,6 +51,11 @@ class IncomeStatement::CategoryStats "AND a.id NOT IN (:tax_advantaged_account_ids)" end + def scope_to_account_ids_sql + return "" if @account_ids.nil? + ActiveRecord::Base.sanitize_sql([ "AND a.id IN (?)", @account_ids ]) + end + def query_sql <<~SQL WITH period_totals AS ( @@ -71,6 +79,7 @@ class IncomeStatement::CategoryStats AND (t.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true AND (t.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true #{exclude_tax_advantaged_sql} + #{scope_to_account_ids_sql} GROUP BY c.id, period, CASE WHEN t.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END ) SELECT diff --git a/app/models/income_statement/family_stats.rb b/app/models/income_statement/family_stats.rb index 8ce0e2e2d..d172d4ebf 100644 --- a/app/models/income_statement/family_stats.rb +++ b/app/models/income_statement/family_stats.rb @@ -1,10 +1,13 @@ class IncomeStatement::FamilyStats - def initialize(family, interval: "month") + def initialize(family, interval: "month", account_ids: nil) @family = family @interval = interval + @account_ids = account_ids end def call + return [] if @account_ids&.empty? + ActiveRecord::Base.connection.select_all(sanitized_query_sql).map do |row| StatRow.new( classification: row["classification"], @@ -47,6 +50,11 @@ class IncomeStatement::FamilyStats "AND a.id NOT IN (:tax_advantaged_account_ids)" end + def scope_to_account_ids_sql + return "" if @account_ids.nil? + ActiveRecord::Base.sanitize_sql([ "AND a.id IN (?)", @account_ids ]) + end + def query_sql <<~SQL WITH period_totals AS ( @@ -68,6 +76,7 @@ class IncomeStatement::FamilyStats AND (t.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true AND (t.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true #{exclude_tax_advantaged_sql} + #{scope_to_account_ids_sql} GROUP BY period, CASE WHEN t.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END ) SELECT diff --git a/app/models/income_statement/totals.rb b/app/models/income_statement/totals.rb index 398bd1377..54fb56732 100644 --- a/app/models/income_statement/totals.rb +++ b/app/models/income_statement/totals.rb @@ -1,14 +1,18 @@ class IncomeStatement::Totals - def initialize(family, transactions_scope:, date_range:, include_trades: true) + def initialize(family, transactions_scope:, date_range:, include_trades: true, included_account_ids: nil) @family = family @transactions_scope = transactions_scope @date_range = date_range @include_trades = include_trades + @included_account_ids = included_account_ids validate_date_range! end def call + # No finance accounts means no transactions to report + return [] if @included_account_ids&.empty? + ActiveRecord::Base.connection.select_all(query_sql).map do |row| TotalsRow.new( parent_category_id: row["parent_category_id"], @@ -74,6 +78,7 @@ class IncomeStatement::Totals AND a.family_id = :family_id AND a.status IN ('draft', 'active') #{exclude_tax_advantaged_sql} + #{include_finance_accounts_sql} GROUP BY c.id, c.parent_id, CASE WHEN at.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END; SQL end @@ -105,6 +110,7 @@ class IncomeStatement::Totals AND a.family_id = :family_id AND a.status IN ('draft', 'active') #{exclude_tax_advantaged_sql} + #{include_finance_accounts_sql} GROUP BY c.id, c.parent_id, CASE WHEN at.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END SQL end @@ -133,6 +139,9 @@ class IncomeStatement::Totals ids = @family.tax_advantaged_account_ids params[:tax_advantaged_account_ids] = ids if ids.present? + # Add included account IDs for per-user finance scoping + params[:included_account_ids] = @included_account_ids if @included_account_ids + params end @@ -144,6 +153,12 @@ class IncomeStatement::Totals "AND a.id NOT IN (:tax_advantaged_account_ids)" end + # Returns SQL clause to filter to only accounts included in the user's finances. + def include_finance_accounts_sql + return "" if @included_account_ids.nil? + "AND a.id IN (:included_account_ids)" + end + def budget_excluded_kinds_sql @budget_excluded_kinds_sql ||= Transaction::BUDGET_EXCLUDED_KINDS.map { |k| "'#{k}'" }.join(", ") end diff --git a/app/models/indexa_capital_account.rb b/app/models/indexa_capital_account.rb index 4e600a0b7..e6d225ecc 100644 --- a/app/models/indexa_capital_account.rb +++ b/app/models/indexa_capital_account.rb @@ -12,6 +12,7 @@ class IndexaCapitalAccount < ApplicationRecord has_one :linked_account, through: :account_provider, source: :account validates :name, :currency, presence: true + validates :indexa_capital_account_id, uniqueness: { scope: :indexa_capital_item_id, allow_nil: true } # Scopes scope :with_linked, -> { joins(:account_provider) } diff --git a/app/models/investment.rb b/app/models/investment.rb index c0c9c918f..3d18a8334 100644 --- a/app/models/investment.rb +++ b/app/models/investment.rb @@ -30,11 +30,20 @@ class Investment < ApplicationRecord "workplace_pension_uk" => { short: "Pension", long: "Workplace Pension", region: "uk", tax_treatment: :tax_deferred }, # === Canada === - "rrsp" => { short: "RRSP", long: "Registered Retirement Savings Plan", region: "ca", tax_treatment: :tax_deferred }, "tfsa" => { short: "TFSA", long: "Tax-Free Savings Account", region: "ca", tax_treatment: :tax_exempt }, + "rrsp" => { short: "RRSP", long: "Registered Retirement Savings Plan", region: "ca", tax_treatment: :tax_deferred }, + "non-registered" => { short: "Non-Registered", long: "Non-Registered Investment Account", region: "ca", tax_treatment: :taxable }, + "fhsa" => { short: "FHSA", long: "First Home Savings Account", region: "ca", tax_treatment: :tax_exempt }, + "rdsp" => { short: "RDSP", long: "Registered Disability Savings Plan", region: "ca", tax_treatment: :tax_advantaged }, "resp" => { short: "RESP", long: "Registered Education Savings Plan", region: "ca", tax_treatment: :tax_advantaged }, + "dpsp" => { short: "DPSP", long: "Deferred Profit Sharing Plan", region: "ca", tax_treatment: :tax_deferred }, + "prpp" => { short: "PRPP", long: "Pooled Registered Pension Plan", region: "ca", tax_treatment: :tax_deferred }, "lira" => { short: "LIRA", long: "Locked-In Retirement Account", region: "ca", tax_treatment: :tax_deferred }, "rrif" => { short: "RRIF", long: "Registered Retirement Income Fund", region: "ca", tax_treatment: :tax_deferred }, + "lif" => { short: "LIF", long: "Life Income Fund", region: "ca", tax_treatment: :tax_deferred }, + "lrif" => { short: "LRIF", long: "Locked-In Retirement Income Fund", region: "ca", tax_treatment: :tax_deferred }, + "prif" => { short: "PRIF", long: "Prescribed Registered Retirement Income Fund", region: "ca", tax_treatment: :tax_deferred }, + "rlif" => { short: "RLIF", long: "Restricted Life Income Fund", region: "ca", tax_treatment: :tax_deferred }, # === Australia === "super" => { short: "Super", long: "Superannuation", region: "au", tax_treatment: :tax_deferred }, diff --git a/app/models/investment_flow_statement.rb b/app/models/investment_flow_statement.rb index d17a71266..66d68b7a9 100644 --- a/app/models/investment_flow_statement.rb +++ b/app/models/investment_flow_statement.rb @@ -1,21 +1,28 @@ class InvestmentFlowStatement include Monetizable - attr_reader :family + attr_reader :family, :user - def initialize(family) + def initialize(family, user: nil) @family = family + @user = user end # Get contribution/withdrawal totals for a period def period_totals(period: Period.current_month) - transactions = family.transactions + scope = family.transactions .visible .excluding_pending .where(entries: { date: period.date_range }) .where(kind: %w[standard investment_contribution]) .where(investment_activity_label: %w[Contribution Withdrawal]) + if user + scope = scope.joins(entry: :account).merge(Account.included_in_finances_for(user)) + end + + transactions = scope + contributions = transactions.where(investment_activity_label: "Contribution").sum("entries.amount").abs withdrawals = transactions.where(investment_activity_label: "Withdrawal").sum("entries.amount").abs diff --git a/app/models/investment_statement.rb b/app/models/investment_statement.rb index 11cfdffa4..4475f1fce 100644 --- a/app/models/investment_statement.rb +++ b/app/models/investment_statement.rb @@ -5,17 +5,18 @@ class InvestmentStatement monetize :total_contributions, :total_dividends, :total_interest, :unrealized_gains - attr_reader :family + attr_reader :family, :user - def initialize(family) + def initialize(family, user: nil) @family = family + @user = user || Current.user end # Get totals for a specific period def totals(period: Period.current_month) trades_in_period = family.trades .joins(:entry) - .where(entries: { date: period.date_range }) + .where(entries: { date: period.date_range, account_id: investment_account_ids }) result = totals_query(trades_scope: trades_in_period) @@ -161,7 +162,11 @@ class InvestmentStatement # Investment accounts def investment_accounts - @investment_accounts ||= family.accounts.visible.where(accountable_type: %w[Investment Crypto]) + @investment_accounts ||= begin + scope = family.accounts.visible.where(accountable_type: %w[Investment Crypto]) + scope = scope.included_in_finances_for(user) if user + scope + end end private @@ -181,11 +186,15 @@ class InvestmentStatement HoldingAllocation = Data.define(:security, :amount, :weight, :trend) + def investment_account_ids + @investment_account_ids ||= investment_accounts.pluck(:id) + end + def totals_query(trades_scope:) sql_hash = Digest::MD5.hexdigest(trades_scope.to_sql) Rails.cache.fetch([ - "investment_statement", "totals_query", family.id, sql_hash, family.entries_cache_version + "investment_statement", "totals_query", family.id, user&.id, sql_hash, family.entries_cache_version ]) { Totals.new(family, trades_scope: trades_scope).call } end diff --git a/app/models/invitation.rb b/app/models/invitation.rb index afafd7852..99ecb0da7 100644 --- a/app/models/invitation.rb +++ b/app/models/invitation.rb @@ -13,9 +13,11 @@ class Invitation < ApplicationRecord validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } validates :role, presence: true, inclusion: { in: %w[admin member guest] } validates :token, presence: true, uniqueness: true - validates_uniqueness_of :email, scope: :family_id, message: "has already been invited to this family" + validate :no_duplicate_pending_invitation_in_family validate :inviter_is_admin + validate :no_other_pending_invitation, on: :create + before_validation :normalize_email before_validation :generate_token, on: :create before_create :set_expiration @@ -34,6 +36,7 @@ class Invitation < ApplicationRecord transaction do user.update!(family_id: family_id, role: role.to_s) update!(accepted_at: Time.current) + auto_share_existing_accounts(user) if family.share_all_by_default? end true end @@ -57,7 +60,49 @@ class Invitation < ApplicationRecord self.expires_at = 3.days.from_now end + def normalize_email + self.email = email.to_s.strip.downcase if email.present? + end + + def no_other_pending_invitation + return if email.blank? + + existing = if self.class.encryption_ready? + self.class.pending.where(email: email).where.not(family_id: family_id).exists? + else + self.class.pending.where("LOWER(email) = ?", email.downcase).where.not(family_id: family_id).exists? + end + + if existing + errors.add(:email, "already has a pending invitation from another family") + end + end + + def no_duplicate_pending_invitation_in_family + return if email.blank? + + scope = self.class.pending.where(family_id: family_id) + scope = scope.where.not(id: id) if persisted? + + exists = if self.class.encryption_ready? + scope.where(email: email).exists? + else + scope.where("LOWER(email) = ?", email.to_s.strip.downcase).exists? + end + + errors.add(:email, "has already been invited to this family") if exists + end + def inviter_is_admin inviter.admin? end + + def auto_share_existing_accounts(user) + records = family.accounts.where.not(owner_id: user.id).pluck(:id).map do |account_id| + { account_id: account_id, user_id: user.id, permission: "read_write", + include_in_finances: true, created_at: Time.current, updated_at: Time.current } + end + + AccountShare.insert_all(records, unique_by: %i[account_id user_id]) if records.any? + end end diff --git a/app/models/loan.rb b/app/models/loan.rb index 5a206e7af..ec56d65b8 100644 --- a/app/models/loan.rb +++ b/app/models/loan.rb @@ -3,9 +3,9 @@ class Loan < ApplicationRecord SUBTYPES = { "mortgage" => { short: "Mortgage", long: "Mortgage" }, - "student" => { short: "Student", long: "Student Loan" }, - "auto" => { short: "Auto", long: "Auto Loan" }, - "other" => { short: "Other", long: "Other Loan" } + "student" => { short: "Student Loan", long: "Student Loan" }, + "auto" => { short: "Auto Loan", long: "Auto Loan" }, + "other" => { short: "Other Loan", long: "Other Loan" } }.freeze def monthly_payment diff --git a/app/models/lunchflow_account.rb b/app/models/lunchflow_account.rb index c7ce80f7e..c38ae5082 100644 --- a/app/models/lunchflow_account.rb +++ b/app/models/lunchflow_account.rb @@ -15,6 +15,7 @@ class LunchflowAccount < ApplicationRecord has_one :linked_account, through: :account_provider, source: :account validates :name, :currency, presence: true + validates :account_id, uniqueness: { scope: :lunchflow_item_id, allow_nil: true } # Helper to get account using account_providers system def current_account diff --git a/app/models/mercury_account.rb b/app/models/mercury_account.rb index a4635cfc7..f9b0ee527 100644 --- a/app/models/mercury_account.rb +++ b/app/models/mercury_account.rb @@ -15,6 +15,7 @@ class MercuryAccount < ApplicationRecord has_one :linked_account, through: :account_provider, source: :account validates :name, :currency, presence: true + validates :account_id, uniqueness: { scope: :mercury_item_id } # Helper to get account using account_providers system def current_account diff --git a/app/models/plaid_account.rb b/app/models/plaid_account.rb index bb5237586..ae2ecfeae 100644 --- a/app/models/plaid_account.rb +++ b/app/models/plaid_account.rb @@ -19,6 +19,7 @@ class PlaidAccount < ApplicationRecord has_one :linked_account, through: :account_provider, source: :account validates :name, :plaid_type, :currency, presence: true + validates :plaid_id, uniqueness: { scope: :plaid_item_id } validate :has_balance # Helper to get account using new system first, falling back to legacy diff --git a/app/models/plaid_account/processor.rb b/app/models/plaid_account/processor.rb index 4faead9d9..4f2af022d 100644 --- a/app/models/plaid_account/processor.rb +++ b/app/models/plaid_account/processor.rb @@ -74,8 +74,11 @@ class PlaidAccount::Processor cash_balance: balance_calculator.cash_balance ) + new_account = account.new_record? account.save! + account.auto_share_with_family! if new_account && account.family.share_all_by_default? + # Create account provider link if it doesn't exist unless account_provider AccountProvider.find_or_create_by!( diff --git a/app/models/plaid_account/transactions/category_matcher.rb b/app/models/plaid_account/transactions/category_matcher.rb index 87652109f..263ec0445 100644 --- a/app/models/plaid_account/transactions/category_matcher.rb +++ b/app/models/plaid_account/transactions/category_matcher.rb @@ -97,7 +97,6 @@ class PlaidAccount::Transactions::CategoryMatcher user_categories.map do |user_category| { id: user_category.id, - classification: user_category.classification, name: normalize_user_category_name(user_category.name) } end diff --git a/app/models/plaid_item/syncer.rb b/app/models/plaid_item/syncer.rb index 74d66a58b..1f90f68c6 100644 --- a/app/models/plaid_item/syncer.rb +++ b/app/models/plaid_item/syncer.rb @@ -12,8 +12,16 @@ class PlaidItem::Syncer sync.update!(status_text: "Importing accounts from Plaid...") if sync.respond_to?(:status_text) plaid_item.import_latest_plaid_data - # Phase 2: Collect setup statistics + # Phase 2: Process the raw Plaid data and create/update internal domain objects + # This must happen before the linked/unlinked check because process_accounts + # is what creates Account and AccountProvider records for new PlaidAccounts. + sync.update!(status_text: "Processing accounts...") if sync.respond_to?(:status_text) + mark_import_started(sync) + plaid_item.process_accounts + + # Phase 3: Collect setup statistics (now that accounts have been processed) sync.update!(status_text: "Checking account configuration...") if sync.respond_to?(:status_text) + plaid_item.plaid_accounts.reload collect_setup_stats(sync, provider_accounts: plaid_item.plaid_accounts) # Check for unlinked accounts and update pending_account_setup flag @@ -25,14 +33,9 @@ class PlaidItem::Syncer plaid_item.update!(pending_account_setup: false) if plaid_item.respond_to?(:pending_account_setup=) end - # Phase 3: Process the raw Plaid data and updates internal domain objects + # Phase 4: Schedule balance calculations for linked accounts linked_accounts = plaid_item.plaid_accounts.select { |pa| pa.current_account.present? } if linked_accounts.any? - sync.update!(status_text: "Processing transactions...") if sync.respond_to?(:status_text) - mark_import_started(sync) - plaid_item.process_accounts - - # Phase 4: Schedule balance calculations sync.update!(status_text: "Calculating balances...") if sync.respond_to?(:status_text) plaid_item.schedule_account_syncs( parent_sync: sync, diff --git a/app/models/provider/binance.rb b/app/models/provider/binance.rb new file mode 100644 index 000000000..498084882 --- /dev/null +++ b/app/models/provider/binance.rb @@ -0,0 +1,141 @@ +class Provider::Binance + include HTTParty + extend SslConfigurable + + class Error < StandardError; end + class AuthenticationError < Error; end + class RateLimitError < Error; end + class ApiError < Error; end + class InvalidSymbolError < ApiError; end + + # Pipelock false positive: This constant and the base_uri below trigger a "Credential in URL" + # warning because of the presence of @api_key and @api_secret variables in this file. + # Pipelock incorrectly interprets the '@' in Ruby instance variables as a password delimiter + # in an URL (e.g. https://user:password@host). + SPOT_BASE_URL = "https://api.binance.com".freeze + + base_uri SPOT_BASE_URL + default_options.merge!({ timeout: 30 }.merge(httparty_ssl_options)) + + attr_reader :api_key, :api_secret + + def initialize(api_key:, api_secret:) + @api_key = api_key + @api_secret = api_secret + end + + # Spot wallet — requires signed request + def get_spot_account + signed_get("/api/v3/account") + end + + # Margin account — requires signed request + def get_margin_account + signed_get("/sapi/v1/margin/account") + end + + # Simple Earn flexible positions — requires signed request + def get_simple_earn_flexible + signed_get("/sapi/v1/simple-earn/flexible/position") + end + + # Simple Earn locked positions — requires signed request + def get_simple_earn_locked + signed_get("/sapi/v1/simple-earn/locked/position") + end + + # Public endpoint — no auth needed + # symbol e.g. "BTCUSDT" + # Returns price string or nil on failure + def get_spot_price(symbol) + response = self.class.get("/api/v3/ticker/price", query: { symbol: symbol }) + data = handle_response(response) + data["price"] + rescue StandardError => e + Rails.logger.warn("Provider::Binance: failed to fetch price for #{symbol}: #{e.message}") + nil + end + + # Public endpoint — fetch historical kline close price for a date + # symbol e.g. "BTCUSDT", date e.g. Date or Time + def get_historical_price(symbol, date) + timestamp = date.to_time.utc.beginning_of_day.to_i * 1000 + + response = self.class.get("/api/v3/klines", query: { + symbol: symbol, + interval: "1d", + startTime: timestamp, + limit: 1 + }) + + data = handle_response(response) + + return nil if data.blank? || data.first.blank? + + # Binance klines format: [ Open time, Open, High, Low, Close (index 4), ... ] + data.first[4] + rescue StandardError => e + Rails.logger.warn("Provider::Binance: failed to fetch historical price for #{symbol} on #{date}: #{e.message}") + nil + end + + # Signed trade history for a single symbol, e.g. "BTCUSDT". + # Pass from_id to fetch only trades with id >= from_id (for incremental sync). + def get_spot_trades(symbol, limit: 1000, from_id: nil) + params = { "symbol" => symbol, "limit" => limit.to_s } + params["fromId"] = from_id.to_s if from_id + signed_get("/api/v3/myTrades", extra_params: params) + end + + private + + def signed_get(path, extra_params: {}) + params = timestamp_params.merge(extra_params) + params["signature"] = sign(params) + + response = self.class.get( + path, + query: params, + headers: auth_headers + ) + + handle_response(response) + end + + def timestamp_params + { "timestamp" => (Time.current.to_f * 1000).to_i.to_s, "recvWindow" => "5000" } + end + + # HMAC-SHA256 of the query string + def sign(params) + query_string = URI.encode_www_form(params.sort) + OpenSSL::HMAC.hexdigest("sha256", api_secret, query_string) + end + + def auth_headers + { "X-MBX-APIKEY" => api_key } + end + + def handle_response(response) + parsed = response.parsed_response + + case response.code + when 200..299 + parsed + when 401 + raise AuthenticationError, extract_error_message(parsed) || "Unauthorized" + when 429 + raise RateLimitError, "Rate limit exceeded" + else + msg = extract_error_message(parsed) || "API error: #{response.code}" + raise InvalidSymbolError, msg if parsed.is_a?(Hash) && parsed["code"] == -1121 + raise ApiError, msg + end + end + + def extract_error_message(parsed) + return parsed if parsed.is_a?(String) + return nil unless parsed.is_a?(Hash) + parsed["msg"] || parsed["message"] || parsed["error"] + end +end diff --git a/app/models/provider/binance_adapter.rb b/app/models/provider/binance_adapter.rb new file mode 100644 index 000000000..2d688f7cd --- /dev/null +++ b/app/models/provider/binance_adapter.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +class Provider::BinanceAdapter < Provider::Base + include Provider::Syncable + include Provider::InstitutionMetadata + + # Register this adapter with the factory + Provider::Factory.register("BinanceAccount", self) + + # Define which account types this provider supports + def self.supported_account_types + %w[Crypto] + end + + # Returns connection configurations for this provider + def self.connection_configs(family:) + return [] unless family.can_connect_binance? + + [ { + key: "binance", + name: "Binance", + description: "Link to a Binance wallet", + can_connect: true, + new_account_path: ->(accountable_type, return_to) { + Rails.application.routes.url_helpers.select_accounts_binance_items_path( + accountable_type: accountable_type, + return_to: return_to + ) + }, + existing_account_path: ->(account_id) { + Rails.application.routes.url_helpers.select_existing_account_binance_items_path( + account_id: account_id + ) + } + } ] + end + + def provider_name + "binance" + end + + # Build a Binance provider instance with family-specific credentials + # @param family [Family] The family to get credentials for (required) + # @return [Provider::Binance, nil] Returns nil if credentials are not configured + def self.build_provider(family: nil) + return nil unless family.present? + + # Get family-specific credentials + binance_item = family.binance_items.where.not(api_key: nil).order(created_at: :desc).first + return nil unless binance_item&.credentials_configured? + + Provider::Binance.new( + api_key: binance_item.api_key, + api_secret: binance_item.api_secret + ) + end + + def sync_path + Rails.application.routes.url_helpers.sync_binance_item_path(item) + end + + def item + provider_account.binance_item + end + + def can_delete_holdings? + false + end + + def institution_domain + metadata = provider_account.institution_metadata || {} + + domain = metadata["domain"] + url = metadata["url"] + + # Derive domain from URL if missing + if domain.blank? && url.present? + begin + domain = URI.parse(url).host&.gsub(/^www\./, "") + rescue URI::InvalidURIError + Rails.logger.warn("Invalid institution URL for Binance account #{provider_account.id}: #{url}") + end + end + + domain || item&.institution_domain + end + + def institution_name + metadata = provider_account.institution_metadata || {} + metadata["name"] || item&.institution_name + end + + def institution_url + metadata = provider_account.institution_metadata || {} + metadata["url"] || item&.institution_url + end + + def institution_color + metadata = provider_account.institution_metadata || {} + metadata["color"] || item&.institution_color + end +end diff --git a/app/models/provider/coinstats.rb b/app/models/provider/coinstats.rb index 3ffa814b4..1689c6189 100644 --- a/app/models/provider/coinstats.rb +++ b/app/models/provider/coinstats.rb @@ -63,6 +63,258 @@ class Provider::Coinstats < Provider [] end + # Get the list of exchange connections supported by CoinStats + # https://coinstats.app/api-docs/openapi/get-exchanges + def get_exchanges + with_provider_response do + res = self.class.get("#{BASE_URL}/exchange/support", headers: auth_headers) + handle_response(res) + end + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error "CoinStats API: GET /exchange/support failed: #{e.class}: #{e.message}" + raise Error, "CoinStats API request failed: #{e.message}" + end + + def exchange_options + response = get_exchanges + + unless response.success? + Rails.logger.warn("CoinStats: failed to fetch exchanges: #{response.error&.message}") + return [] + end + + Array(response.data).filter_map do |exchange| + exchange = exchange.with_indifferent_access + connection_id = exchange[:connectionId] + next unless connection_id.present? + + { + connection_id: connection_id.to_s, + name: exchange[:name].presence || connection_id.to_s.titleize, + icon: exchange[:icon], + connection_fields: Array(exchange[:connectionFields]).map do |field| + field = field.with_indifferent_access + { + key: field[:key].to_s, + name: field[:name].presence || field[:key].to_s.humanize + } + end + } + end.sort_by { |exchange| exchange[:name].to_s.downcase } + rescue StandardError => e + Rails.logger.warn("CoinStats: failed to fetch exchanges: #{e.class} - #{e.message}") + [] + end + + # Connect an exchange portfolio and return its portfolio id + # https://coinstats.app/api-docs/openapi/connect-portfolio-exchange + def connect_portfolio_exchange(connection_id:, connection_fields:, name: nil) + with_provider_response do + res = self.class.post( + "#{BASE_URL}/portfolio/exchange", + headers: auth_headers.merge("Content-Type" => "application/json"), + body: { + connectionId: connection_id, + connectionFields: connection_fields, + name: name + }.compact.to_json + ) + handle_response(res) + end + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error "CoinStats API: POST /portfolio/exchange failed: #{e.class}: #{e.message}" + raise Error, "CoinStats API request failed: #{e.message}" + end + + # Get all holdings for a CoinStats portfolio. + # https://coinstats.app/api-docs/openapi/get-portfolio-coins + def get_portfolio_coins(portfolio_id:, page: 1, limit: 100) + with_provider_response do + res = self.class.get( + "#{BASE_URL}/portfolio/coins", + headers: auth_headers, + query: { + portfolioId: portfolio_id, + page: page, + limit: limit + } + ) + handle_response(res) + end + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error "CoinStats API: GET /portfolio/coins failed: #{e.class}: #{e.message}" + raise Error, "CoinStats API request failed: #{e.message}" + end + + def list_portfolio_coins(portfolio_id:, limit: 100) + page = 1 + results = [] + + loop do + response = get_portfolio_coins(portfolio_id: portfolio_id, page: page, limit: limit) + raise response.error unless response.success? + + payload = response.data.with_indifferent_access + page_results = Array(payload[:result]) + results.concat(page_results) + + break if page_results.size < limit + + page += 1 + end + + results + end + + # Get all transactions for a CoinStats portfolio. + # https://coinstats.app/api-docs/openapi/get-portfolio-transactions + def get_portfolio_transactions(portfolio_id:, currency: "USD", page: 1, limit: 100, from: nil, to: nil, coin_id: nil) + with_provider_response do + res = self.class.get( + "#{BASE_URL}/portfolio/transactions", + headers: auth_headers, + query: { + portfolioId: portfolio_id, + currency: currency, + page: page, + limit: limit, + from: from, + to: to, + coinId: coin_id + }.compact + ) + handle_response(res) + end + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error "CoinStats API: GET /portfolio/transactions failed: #{e.class}: #{e.message}" + raise Error, "CoinStats API request failed: #{e.message}" + end + + def list_portfolio_transactions(portfolio_id:, currency: "USD", limit: 100, from: nil, to: nil) + page = 1 + results = [] + + loop do + response = get_portfolio_transactions( + portfolio_id: portfolio_id, + currency: currency, + page: page, + limit: limit, + from: from, + to: to + ) + raise response.error unless response.success? + + payload = response.data.with_indifferent_access + page_results = Array(payload[:data] || payload[:result]) + results.concat(page_results) + + break if page_results.size < limit + + page += 1 + end + + results + end + + # Get transaction data for a specific exchange portfolio. + # https://coinstats.app/api-docs/openapi/get-exchange-transactions + def get_exchange_transactions(portfolio_id:, currency: "USD", page: 1, limit: 100, from: nil, to: nil) + with_provider_response do + res = self.class.get( + "#{BASE_URL}/exchange/transactions", + headers: auth_headers, + query: { + portfolioId: portfolio_id, + currency: currency, + page: page, + limit: limit, + from: from, + to: to + }.compact + ) + handle_response(res) + end + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error "CoinStats API: GET /exchange/transactions failed: #{e.class}: #{e.message}" + raise Error, "CoinStats API request failed: #{e.message}" + end + + def list_exchange_transactions(portfolio_id:, currency: "USD", limit: 100, from: nil, to: nil) + page = 1 + results = [] + + loop do + response = get_exchange_transactions( + portfolio_id: portfolio_id, + currency: currency, + page: page, + limit: limit, + from: from, + to: to + ) + raise response.error unless response.success? + + payload = response.data.with_indifferent_access + page_results = Array(payload[:result] || payload[:data]) + results.concat(page_results) + + break if page_results.size < limit + + page += 1 + end + + results + end + + # Trigger a fresh CoinStats sync for the portfolio. + # https://coinstats.app/api-docs/openapi/sync-portfolio + def sync_portfolio(portfolio_id:) + with_provider_response do + res = self.class.patch( + "#{BASE_URL}/portfolio/sync", + headers: auth_headers, + query: { portfolioId: portfolio_id } + ) + handle_response(res) + end + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error "CoinStats API: PATCH /portfolio/sync failed: #{e.class}: #{e.message}" + raise Error, "CoinStats API request failed: #{e.message}" + end + + # Trigger a fresh CoinStats exchange sync for the portfolio. + # https://coinstats.app/api-docs/openapi/exchange-sync-status + def sync_exchange(portfolio_id:) + with_provider_response do + res = self.class.patch( + "#{BASE_URL}/exchange/sync", + headers: auth_headers, + query: { portfolioId: portfolio_id } + ) + handle_response(res) + end + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error "CoinStats API: PATCH /exchange/sync failed: #{e.class}: #{e.message}" + raise Error, "CoinStats API request failed: #{e.message}" + end + + # Get current sync status for the portfolio. + # https://coinstats.app/api-docs/openapi/get-portfolio-sync-status + def get_portfolio_sync_status(portfolio_id:) + with_provider_response do + res = self.class.get( + "#{BASE_URL}/portfolio/status", + headers: auth_headers, + query: { portfolioId: portfolio_id } + ) + handle_response(res) + end + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error "CoinStats API: GET /portfolio/status failed: #{e.class}: #{e.message}" + raise Error, "CoinStats API request failed: #{e.message}" + end + # Get cryptocurrency balances for multiple wallets in a single request # https://coinstats.app/api-docs/openapi/get-wallet-balances # @param wallets [String] Comma-separated list of wallet addresses in format "blockchain:address" @@ -173,35 +425,58 @@ class Provider::Coinstats < Provider JSON.parse(response.body, symbolize_names: true) when 400 log_api_error(response, "Bad Request") - raise Error, "CoinStats: Invalid request parameters" + raise_api_error(response, fallback: "CoinStats: Invalid request parameters") when 401 log_api_error(response, "Unauthorized") - raise Error, "CoinStats: Invalid or missing API key" + raise_api_error(response, fallback: "CoinStats: Invalid or missing API key") when 403 log_api_error(response, "Forbidden") - raise Error, "CoinStats: Access denied" + raise_api_error(response, fallback: "CoinStats: Access denied") when 404 log_api_error(response, "Not Found") - raise Error, "CoinStats: Resource not found" + raise_api_error(response, fallback: "CoinStats: Resource not found") when 409 log_api_error(response, "Conflict") - raise Error, "CoinStats: Resource conflict" + raise_api_error(response, fallback: "CoinStats: Resource conflict") when 429 log_api_error(response, "Too Many Requests") - raise Error, "CoinStats: Rate limit exceeded, try again later" + raise_api_error(response, fallback: "CoinStats: Rate limit exceeded, try again later") when 500 log_api_error(response, "Internal Server Error") - raise Error, "CoinStats: Server error, try again later" + raise_api_error(response, fallback: "CoinStats: Server error, try again later") when 503 log_api_error(response, "Service Unavailable") - raise Error, "CoinStats: Service temporarily unavailable" + raise_api_error(response, fallback: "CoinStats: Service temporarily unavailable") else log_api_error(response, "Unexpected Error") - raise Error, "CoinStats: An unexpected error occurred" + raise_api_error(response, fallback: "CoinStats: An unexpected error occurred") end end def log_api_error(response, error_type) Rails.logger.error "CoinStats API: #{response.code} #{error_type} - #{response.body}" end + + def raise_api_error(response, fallback:) + error_payload = parse_error_payload(response.body) + message = error_payload[:message].presence || fallback + request_id = error_payload[:request_id].presence + + message = "#{message} (requestId: #{request_id})" if request_id.present? + + raise Error.new(message, details: error_payload.compact.presence) + end + + def parse_error_payload(body) + payload = JSON.parse(body.presence || "{}", symbolize_names: true) + + { + status_code: payload[:statusCode] || payload[:status_code], + message: payload[:message], + request_id: payload[:requestId] || payload[:request_id], + path: payload[:path] + } + rescue JSON::ParserError + {} + end end diff --git a/app/models/provider/llm_concept.rb b/app/models/provider/llm_concept.rb index 5faf233dd..46e6dcd84 100644 --- a/app/models/provider/llm_concept.rb +++ b/app/models/provider/llm_concept.rb @@ -13,6 +13,12 @@ module Provider::LlmConcept raise NotImplementedError, "Subclasses must implement #auto_detect_merchants" end + EnhancedMerchant = Data.define(:merchant_id, :business_url) + + def enhance_provider_merchants(merchants) + raise NotImplementedError, "Subclasses must implement #enhance_provider_merchants" + end + PdfProcessingResult = Data.define(:summary, :document_type, :extracted_data) def supports_pdf_processing? diff --git a/app/models/provider/openai.rb b/app/models/provider/openai.rb index 154b2d02e..60c103a9e 100644 --- a/app/models/provider/openai.rb +++ b/app/models/provider/openai.rb @@ -116,6 +116,33 @@ class Provider::Openai < Provider end end + def enhance_provider_merchants(merchants: [], model: "", family: nil, json_mode: nil) + with_provider_response do + raise Error, "Too many merchants to enhance. Max is 25 per request." if merchants.size > 25 + + effective_model = model.presence || @default_model + + trace = create_langfuse_trace( + name: "openai.enhance_provider_merchants", + input: { merchants: merchants } + ) + + result = ProviderMerchantEnhancer.new( + client, + model: effective_model, + merchants: merchants, + custom_provider: custom_provider?, + langfuse_trace: trace, + family: family, + json_mode: json_mode + ).enhance_merchants + + upsert_langfuse_trace(trace: trace, output: result.map(&:to_h)) + + result + end + end + # Can be disabled via ENV for OpenAI-compatible endpoints that don't support vision # Only vision-capable models (gpt-4o, gpt-4-turbo, gpt-4.1, etc.) support PDF input def supports_pdf_processing?(model: @default_model) @@ -538,7 +565,7 @@ class Provider::Openai < Provider # For error cases, record with zero tokens if error.present? - Rails.logger.info("Recording failed LLM usage - Error: #{error.message}") + Rails.logger.info("Recording failed LLM usage - Error: #{safe_error_message(error)}") # Extract HTTP status code if available from the error http_status_code = extract_http_status_code(error) @@ -553,7 +580,7 @@ class Provider::Openai < Provider total_tokens: 0, estimated_cost: nil, metadata: { - error: error.message, + error: safe_error_message(error), http_status_code: http_status_code } ) @@ -614,11 +641,17 @@ class Provider::Openai < Provider error.status_code elsif error.respond_to?(:response) && error.response.respond_to?(:code) error.response.code.to_i - elsif error.message =~ /(\d{3})/ + elsif safe_error_message(error) =~ /(\d{3})/ # Extract 3-digit HTTP status code from error message $1.to_i else nil end end + + def safe_error_message(error) + error&.message + rescue => e + "(message unavailable: #{e.class})" + end end diff --git a/app/models/provider/openai/auto_categorizer.rb b/app/models/provider/openai/auto_categorizer.rb index 36cdf80bf..3c31c9d5b 100644 --- a/app/models/provider/openai/auto_categorizer.rb +++ b/app/models/provider/openai/auto_categorizer.rb @@ -105,7 +105,7 @@ class Provider::Openai::AutoCategorizer - Return 1 result per transaction - Correlate each transaction by ID (transaction_id) - Attempt to match the most specific category possible (i.e. subcategory over parent category) - - Category and transaction classifications should match (i.e. if transaction is an "expense", the category must have classification of "expense") + - Any category can be used for any transaction regardless of whether the transaction is income or expense - If you don't know the category, return "null" - You should always favor "null" over false positives - Be slightly pessimistic. Only match a category if you're 60%+ confident it is the correct one. diff --git a/app/models/provider/openai/concerns/usage_recorder.rb b/app/models/provider/openai/concerns/usage_recorder.rb index 55f94f052..ef552dfd1 100644 --- a/app/models/provider/openai/concerns/usage_recorder.rb +++ b/app/models/provider/openai/concerns/usage_recorder.rb @@ -47,15 +47,15 @@ module Provider::Openai::Concerns::UsageRecorder # Records failed LLM usage for a family with error details def record_usage_error(model_name, operation:, error:, metadata: {}) - return unless family + return unless family && error - Rails.logger.info("Recording failed LLM usage - Operation: #{operation}, Error: #{error.message}") + Rails.logger.info("Recording failed LLM usage - Operation: #{operation}, Error: #{safe_error_message(error)}") # Extract HTTP status code if available from the error http_status_code = extract_http_status_code(error) error_metadata = metadata.merge( - error: error.message, + error: safe_error_message(error), http_status_code: http_status_code ) @@ -86,11 +86,17 @@ module Provider::Openai::Concerns::UsageRecorder error.status_code elsif error.respond_to?(:response) && error.response.respond_to?(:code) error.response.code.to_i - elsif error.message =~ /(\d{3})/ + elsif safe_error_message(error) =~ /(\d{3})/ # Extract 3-digit HTTP status code from error message $1.to_i else nil end end + + def safe_error_message(error) + error&.message + rescue => e + "(message unavailable: #{e.class})" + end end diff --git a/app/models/provider/openai/provider_merchant_enhancer.rb b/app/models/provider/openai/provider_merchant_enhancer.rb new file mode 100644 index 000000000..4b9350df6 --- /dev/null +++ b/app/models/provider/openai/provider_merchant_enhancer.rb @@ -0,0 +1,402 @@ +class Provider::Openai::ProviderMerchantEnhancer + include Provider::Openai::Concerns::UsageRecorder + + attr_reader :client, :model, :merchants, :custom_provider, :langfuse_trace, :family, :json_mode + + def initialize(client, model: "", merchants:, custom_provider: false, langfuse_trace: nil, family: nil, json_mode: nil) + @client = client + @model = model + @merchants = merchants + @custom_provider = custom_provider + @langfuse_trace = langfuse_trace + @family = family + @json_mode = json_mode || default_json_mode + end + + VALID_JSON_MODES = Provider::Openai::AutoMerchantDetector::VALID_JSON_MODES + + def default_json_mode + env_mode = ENV["LLM_JSON_MODE"] + return env_mode if env_mode.present? && VALID_JSON_MODES.include?(env_mode) + + setting_mode = Setting.openai_json_mode + return setting_mode if setting_mode.present? && VALID_JSON_MODES.include?(setting_mode) + + Provider::Openai::AutoMerchantDetector::JSON_MODE_AUTO + end + + def enhance_merchants + if custom_provider + enhance_merchants_generic + else + enhance_merchants_native + end + end + + def instructions + if custom_provider + simple_instructions + else + detailed_instructions + end + end + + def simple_instructions + <<~INSTRUCTIONS.strip_heredoc + Identify business websites from merchant names. Return JSON only. + + Rules: + 1. Match merchant_id exactly from input + 2. Return the business website URL without "www." prefix + 3. Return "null" if uncertain, generic, or a local business + 4. Only return values if 80%+ confident + + Example output format: + {"merchants": [{"merchant_id": "id_001", "business_url": "amazon.com"}]} + INSTRUCTIONS + end + + def detailed_instructions + <<~INSTRUCTIONS.strip_heredoc + You are an assistant to a consumer personal finance app. + + Given a list of merchant names, identify the business website URL for each. + + Closely follow ALL the rules below: + + - Return 1 result per merchant + - Correlate each merchant by ID (merchant_id) + - Do not include the subdomain in the business_url (i.e. "amazon.com" not "www.amazon.com") + - Be slightly pessimistic. We favor returning "null" over returning a false positive. + - NEVER return a URL for generic or local merchant names (e.g. "Local Diner", "Gas Station", "ATM Withdrawal") + + Determining a value: + + - Attempt to determine the website URL from your knowledge of global and regional businesses + - If no certain match, return "null" + + Example 1 (known business): + + ``` + Merchant name: "Walmart" + + Result: + - business_url: "walmart.com" + ``` + + Example 2 (generic/local business): + + ``` + Merchant name: "Local diner" + + Result: + - business_url: null + ``` + INSTRUCTIONS + end + + private + + def enhance_merchants_native + span = langfuse_trace&.span(name: "enhance_provider_merchants_api_call", input: { + model: model.presence || Provider::Openai::DEFAULT_MODEL, + merchants: merchants + }) + + response = client.responses.create(parameters: { + model: model.presence || Provider::Openai::DEFAULT_MODEL, + input: [ { role: "developer", content: developer_message } ], + text: { + format: { + type: "json_schema", + name: "enhance_provider_merchants", + strict: true, + schema: json_schema + } + }, + instructions: instructions + }) + + Rails.logger.info("Tokens used to enhance provider merchants: #{response.dig("usage", "total_tokens")}") + + result = extract_and_build_response_native(response) + + record_usage( + model.presence || Provider::Openai::DEFAULT_MODEL, + response.dig("usage"), + operation: "enhance_provider_merchants", + metadata: { merchant_count: merchants.size } + ) + + span&.end(output: result.map(&:to_h), usage: response.dig("usage")) + result + rescue => e + span&.end(output: { error: e.message }, level: "ERROR") + raise + end + + def enhance_merchants_generic + if json_mode == Provider::Openai::AutoMerchantDetector::JSON_MODE_AUTO + enhance_merchants_with_auto_mode + else + enhance_merchants_with_mode(json_mode) + end + rescue Faraday::BadRequestError => e + if json_mode == Provider::Openai::AutoMerchantDetector::JSON_MODE_STRICT || json_mode == Provider::Openai::AutoMerchantDetector::JSON_MODE_AUTO + Rails.logger.warn("Strict JSON mode failed for merchant enhancement, falling back to none mode: #{e.message}") + enhance_merchants_with_mode(Provider::Openai::AutoMerchantDetector::JSON_MODE_NONE) + else + raise + end + end + + def enhance_merchants_with_auto_mode + result = enhance_merchants_with_mode(Provider::Openai::AutoMerchantDetector::JSON_MODE_STRICT) + + null_count = result.count { |r| r.business_url.nil? } + missing_count = merchants.size - result.size + failed_count = null_count + missing_count + failed_ratio = merchants.size > 0 ? failed_count.to_f / merchants.size : 0.0 + + if failed_ratio > Provider::Openai::AutoMerchantDetector::AUTO_MODE_NULL_THRESHOLD + Rails.logger.info("Auto mode: #{(failed_ratio * 100).round}% failed in strict mode for merchant enhancement, retrying with none mode") + enhance_merchants_with_mode(Provider::Openai::AutoMerchantDetector::JSON_MODE_NONE) + else + result + end + end + + def enhance_merchants_with_mode(mode) + span = langfuse_trace&.span(name: "enhance_provider_merchants_api_call", input: { + model: model.presence || Provider::Openai::DEFAULT_MODEL, + merchants: merchants, + json_mode: mode + }) + + params = { + model: model.presence || Provider::Openai::DEFAULT_MODEL, + messages: [ + { role: "system", content: instructions }, + { role: "user", content: developer_message_for_generic } + ] + } + + case mode + when Provider::Openai::AutoMerchantDetector::JSON_MODE_STRICT + params[:response_format] = { + type: "json_schema", + json_schema: { + name: "enhance_provider_merchants", + strict: true, + schema: json_schema + } + } + when Provider::Openai::AutoMerchantDetector::JSON_MODE_OBJECT + params[:response_format] = { type: "json_object" } + end + + response = client.chat(parameters: params) + + Rails.logger.info("Tokens used to enhance provider merchants: #{response.dig("usage", "total_tokens")} (json_mode: #{mode})") + + result = extract_and_build_response_generic(response) + + record_usage( + model.presence || Provider::Openai::DEFAULT_MODEL, + response.dig("usage"), + operation: "enhance_provider_merchants", + metadata: { merchant_count: merchants.size, json_mode: mode } + ) + + span&.end(output: result.map(&:to_h), usage: response.dig("usage")) + result + rescue => e + span&.end(output: { error: e.message }, level: "ERROR") + raise + end + + EnhancedMerchant = Provider::LlmConcept::EnhancedMerchant + + def build_response(raw_merchants) + raw_merchants.map do |merchant| + EnhancedMerchant.new( + merchant_id: merchant.dig("merchant_id"), + business_url: normalize_value(merchant.dig("business_url")) + ) + end + end + + def normalize_value(value) + return nil if value.nil? || value == "null" || value.to_s.downcase == "null" + value + end + + def extract_and_build_response_native(response) + message_output = response["output"]&.find { |o| o["type"] == "message" } + raw = message_output&.dig("content", 0, "text") + + raise Provider::Openai::Error, "No message content found in response" if raw.nil? + + merchants_data = JSON.parse(raw).dig("merchants") + build_response(merchants_data) + rescue JSON::ParserError => e + raise Provider::Openai::Error, "Invalid JSON in merchant enhancement: #{e.message}" + end + + def extract_and_build_response_generic(response) + raw = response.dig("choices", 0, "message", "content") + parsed = parse_json_flexibly(raw) + + merchants_data = parsed.dig("merchants") || + parsed.dig("results") || + (parsed.is_a?(Array) ? parsed : nil) + + raise Provider::Openai::Error, "Could not find merchants in response" if merchants_data.nil? + + merchants_data.map! do |m| + { + "merchant_id" => m["merchant_id"] || m["id"], + "business_url" => m["business_url"] || m["url"] || m["website"] + } + end + + build_response(merchants_data) + end + + # Reuse flexible JSON parsing from AutoMerchantDetector + def parse_json_flexibly(raw) + return {} if raw.blank? + + cleaned = strip_thinking_tags(raw) + + JSON.parse(cleaned) + rescue JSON::ParserError + # Strategy 1: Closed markdown code blocks + if cleaned =~ /```(?:json)?\s*(\{[\s\S]*?\})\s*```/m + matches = cleaned.scan(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/m).flatten + matches.reverse_each do |match| + begin + return JSON.parse(match) + rescue JSON::ParserError + next + end + end + end + + # Strategy 2: Unclosed markdown code blocks + if cleaned =~ /```(?:json)?\s*(\{[\s\S]*\})\s*$/m + begin + return JSON.parse($1) + rescue JSON::ParserError + end + end + + # Strategy 3: Find JSON object with "merchants" key + if cleaned =~ /(\{"merchants"\s*:\s*\[[\s\S]*\]\s*\})/m + matches = cleaned.scan(/(\{"merchants"\s*:\s*\[[\s\S]*?\]\s*\})/m).flatten + matches.reverse_each do |match| + begin + return JSON.parse(match) + rescue JSON::ParserError + next + end + end + begin + return JSON.parse($1) + rescue JSON::ParserError + end + end + + # Strategy 4: Find any JSON object + if cleaned =~ /(\{[\s\S]*\})/m + begin + return JSON.parse($1) + rescue JSON::ParserError + end + end + + raise Provider::Openai::Error, "Could not parse JSON from response: #{raw.truncate(200)}" + end + + def strip_thinking_tags(raw) + if raw.include?("") + if raw =~ /<\/think>\s*([\s\S]*)/m + after_thinking = $1.strip + return after_thinking if after_thinking.present? + end + if raw =~ /([\s\S]*)/m + return $1 + end + end + raw + end + + def json_schema + { + type: "object", + properties: { + merchants: { + type: "array", + description: "An array of merchant website detections", + items: { + type: "object", + properties: { + merchant_id: { + type: "string", + description: "The internal ID of the merchant", + enum: merchants.map { |m| m[:id] } + }, + business_url: { + type: [ "string", "null" ], + description: "The website URL of the business, or `null` if uncertain" + } + }, + required: [ "merchant_id", "business_url" ], + additionalProperties: false + } + } + }, + required: [ "merchants" ], + additionalProperties: false + } + end + + def developer_message + <<~MESSAGE.strip_heredoc + Identify the business website URL for each of the following merchants: + + ```json + #{merchants.to_json} + ``` + + Return "null" if you are not 80%+ confident in your answer. + MESSAGE + end + + def developer_message_for_generic + <<~MESSAGE.strip_heredoc + MERCHANTS TO IDENTIFY: + #{format_merchants_simply} + + EXAMPLES of correct website detection: + - "Amazon" → business_url: "amazon.com" + - "Starbucks" → business_url: "starbucks.com" + - "Netflix" → business_url: "netflix.com" + - "Local Diner" → business_url: "null" (generic/unknown) + - "ATM Withdrawal" → business_url: "null" (generic) + + IMPORTANT: + - Return "null" (as a string) if you cannot confidently identify the business website + - Don't include "www." in URLs + + Respond with ONLY this JSON format (no other text): + {"merchants": [{"merchant_id": "...", "business_url": "..."}]} + MESSAGE + end + + def format_merchants_simply + merchants.map do |m| + "- ID: #{m[:id]}, Name: #{m[:name].to_json}" + end.join("\n") + end +end diff --git a/app/models/provider/yahoo_finance.rb b/app/models/provider/yahoo_finance.rb index 75b82520c..d4a992184 100644 --- a/app/models/provider/yahoo_finance.rb +++ b/app/models/provider/yahoo_finance.rb @@ -25,12 +25,13 @@ class Provider::YahooFinance < Provider # Pool of modern browser user-agents to rotate through # Based on https://github.com/ranaroussi/yfinance/pull/2277 + # UPDATED user-agents string on 2026-02-27 with current versions of browsers (Chrome 145, Firefox 148, Safari 26) USER_AGENTS = [ - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0" + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_7_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:148.0) Gecko/20100101 Firefox/148.0" ].freeze def initialize @@ -39,21 +40,11 @@ class Provider::YahooFinance < Provider end def healthy? - begin - # Test with a known stable ticker (Apple) - response = client.get("#{base_url}/v8/finance/chart/AAPL") do |req| - req.params["interval"] = "1d" - req.params["range"] = "1d" - end - - data = JSON.parse(response.body) - result = data.dig("chart", "result") - health_status = result.present? && result.any? - - health_status - rescue => e - false - end + data = fetch_authenticated_chart("AAPL", { "interval" => "1d", "range" => "1d" }) + result = data.dig("chart", "result") + result.present? && result.any? + rescue => e + false end def usage @@ -201,6 +192,9 @@ class Provider::YahooFinance < Provider req.params["crumb"] = crumb end data = JSON.parse(response.body) + if data.dig("quoteSummary", "error", "code") == "Unauthorized" + raise AuthenticationError, "Yahoo Finance authentication failed after crumb refresh" + end end result = data.dig("quoteSummary", "result", 0) @@ -271,14 +265,13 @@ class Provider::YahooFinance < Provider period2 = end_date.end_of_day.to_time.utc.to_i throttle_request - response = client.get("#{base_url}/v8/finance/chart/#{symbol}") do |req| - req.params["period1"] = period1 - req.params["period2"] = period2 - req.params["interval"] = "1d" - req.params["includeAdjustedClose"] = true - end + data = fetch_authenticated_chart(symbol, { + "period1" => period1, + "period2" => period2, + "interval" => "1d", + "includeAdjustedClose" => true + }) - data = JSON.parse(response.body) chart_data = data.dig("chart", "result", 0) raise Error, "No chart data found for #{symbol}" unless chart_data @@ -452,24 +445,48 @@ class Provider::YahooFinance < Provider rates end + # Makes a single authenticated GET to /v8/finance/chart/:symbol. + # If Yahoo returns a stale-crumb error (200 OK with Unauthorized body), + # clears the crumb cache and retries once with fresh credentials. + def fetch_authenticated_chart(symbol, params) + cookie, crumb = fetch_cookie_and_crumb + response = authenticated_client(cookie).get("#{base_url}/v8/finance/chart/#{symbol}") do |req| + params.each { |k, v| req.params[k] = v } + req.params["crumb"] = crumb + end + data = JSON.parse(response.body) + + if data.dig("chart", "error", "code") == "Unauthorized" + clear_crumb_cache + cookie, crumb = fetch_cookie_and_crumb + response = authenticated_client(cookie).get("#{base_url}/v8/finance/chart/#{symbol}") do |req| + params.each { |k, v| req.params[k] = v } + req.params["crumb"] = crumb + end + data = JSON.parse(response.body) + if data.dig("chart", "error", "code") == "Unauthorized" + raise AuthenticationError, "Yahoo Finance authentication failed after crumb refresh" + end + end + + data + end + def fetch_chart_data(symbol, start_date, end_date, &block) period1 = start_date.to_time.utc.to_i period2 = end_date.end_of_day.to_time.utc.to_i begin throttle_request - response = client.get("#{base_url}/v8/finance/chart/#{symbol}") do |req| - req.params["period1"] = period1 - req.params["period2"] = period2 - req.params["interval"] = "1d" - req.params["includeAdjustedClose"] = true - end - - data = JSON.parse(response.body) + data = fetch_authenticated_chart(symbol, { + "period1" => period1, + "period2" => period2, + "interval" => "1d", + "includeAdjustedClose" => true + }) # Check for Yahoo Finance errors if data.dig("chart", "error") - error_msg = data.dig("chart", "error", "description") || "Unknown Yahoo Finance error" return nil end @@ -489,7 +506,7 @@ class Provider::YahooFinance < Provider end results.sort_by(&:date) - rescue Faraday::Error => e + rescue Faraday::Error, JSON::ParserError => e nil end end diff --git a/app/models/provider_merchant.rb b/app/models/provider_merchant.rb index 16f389585..110a2535c 100644 --- a/app/models/provider_merchant.rb +++ b/app/models/provider_merchant.rb @@ -12,8 +12,7 @@ class ProviderMerchant < Merchant family_merchant = family.merchants.create!( name: attributes[:name].presence || name, color: attributes[:color].presence || FamilyMerchant::COLORS.sample, - logo_url: logo_url, - website_url: website_url + website_url: attributes[:website_url].presence || website_url ) # Update only this family's transactions to point to new merchant @@ -23,6 +22,17 @@ class ProviderMerchant < Merchant end end + # Generate logo URL from website_url using BrandFetch, if configured. + def generate_logo_url_from_website! + if website_url.present? && Setting.brand_fetch_client_id.present? + domain = extract_domain(website_url) + size = Setting.brand_fetch_logo_size + update!(logo_url: "https://cdn.brandfetch.io/#{domain}/icon/fallback/lettermark/w/#{size}/h/#{size}?c=#{Setting.brand_fetch_client_id}") + elsif website_url.blank? + update!(logo_url: nil) + end + end + # Unlink from family's transactions (set merchant_id to null). # Does NOT delete the ProviderMerchant since it may be used by other families. # Tracks the unlink in FamilyMerchantAssociation so it shows as "recently unlinked". @@ -33,4 +43,13 @@ class ProviderMerchant < Merchant association = FamilyMerchantAssociation.find_or_initialize_by(family: family, merchant: self) association.update!(unlinked_at: Time.current) end + + private + + def extract_domain(url) + normalized_url = url.start_with?("http://", "https://") ? url : "https://#{url}" + URI.parse(normalized_url).host&.sub(/\Awww\./, "") + rescue URI::InvalidURIError + url.sub(/\Awww\./, "") + end end diff --git a/app/models/provider_merchant/enhancer.rb b/app/models/provider_merchant/enhancer.rb new file mode 100644 index 000000000..a3dc5a5a7 --- /dev/null +++ b/app/models/provider_merchant/enhancer.rb @@ -0,0 +1,98 @@ +class ProviderMerchant::Enhancer + BATCH_SIZE = 25 + + def initialize(family) + @family = family + end + + def enhance + return { enhanced: 0, deduplicated: 0 } unless llm_provider + return { enhanced: 0, deduplicated: 0 } if unenhanced_merchants.none? + + Rails.logger.info("Enhancing #{unenhanced_merchants.count} provider merchants for family #{@family.id}") + + enhanced_count = 0 + deduplicated_count = 0 + + unenhanced_merchants.each_slice(BATCH_SIZE) do |batch| + result = llm_provider.enhance_provider_merchants( + merchants: batch.map { |m| { id: m.id, name: m.name } }, + family: @family + ) + + next unless result.success? + + result.data.each do |enhancement| + next unless enhancement.business_url.present? + + merchant = batch.find { |m| m.id == enhancement.merchant_id } + next unless merchant + next if merchant.website_url.present? # Skip if already enhanced (race condition guard) + + # Step 1: Update the provider merchant with website + logo + updates = { website_url: enhancement.business_url } + updates[:logo_url] = build_logo_url(enhancement.business_url) if Setting.brand_fetch_client_id.present? + merchant.update!(updates) + enhanced_count += 1 + + # Step 2: Deduplicate — find other merchants with the same website_url + # and merge them INTO this provider merchant (prefer provider over AI) + deduplicated_count += deduplicate_by_website(merchant, enhancement.business_url) + rescue ActiveRecord::RecordInvalid => e + Rails.logger.error("Failed to enhance merchant #{merchant.id}: #{e.message}") + end + end + + Rails.logger.info("Enhanced #{enhanced_count} merchants, deduplicated #{deduplicated_count} for family #{@family.id}") + + { enhanced: enhanced_count, deduplicated: deduplicated_count } + end + + private + + def deduplicate_by_website(target_merchant, website_url) + # Find duplicate provider merchants assigned to this family with the same website_url. + # Excludes FamilyMerchants — user-curated merchants should never be touched by dedup. + duplicates = @family.assigned_merchants + .where(type: "ProviderMerchant") + .where(website_url: website_url) + .where.not(id: target_merchant.id) + + return 0 if duplicates.none? + + count = 0 + duplicates.each do |duplicate| + # Reassign family's transactions from duplicate to target + @family.transactions.where(merchant_id: duplicate.id) + .update_all(merchant_id: target_merchant.id) + count += 1 + end + count + end + + def llm_provider + @llm_provider ||= Provider::Registry.get_provider(:openai) + end + + def unenhanced_merchants + @unenhanced_merchants ||= @family.assigned_merchants + .where(type: "ProviderMerchant") + .where(website_url: [ nil, "" ]) + .to_a + end + + def build_logo_url(business_url) + return nil unless Setting.brand_fetch_client_id.present? && business_url.present? + domain = extract_domain(business_url) + return nil unless domain.present? + size = Setting.brand_fetch_logo_size + "https://cdn.brandfetch.io/#{domain}/icon/fallback/lettermark/w/#{size}/h/#{size}?c=#{Setting.brand_fetch_client_id}" + end + + def extract_domain(url) + normalized_url = url.start_with?("http://", "https://") ? url : "https://#{url}" + URI.parse(normalized_url).host&.sub(/\Awww\./, "") + rescue URI::InvalidURIError + url.sub(/\Awww\./, "") + end +end diff --git a/app/models/qif_import.rb b/app/models/qif_import.rb new file mode 100644 index 000000000..90a867d28 --- /dev/null +++ b/app/models/qif_import.rb @@ -0,0 +1,417 @@ +class QifImport < Import + after_create :set_default_config + + # The date format used to parse the raw QIF file's D-fields (e.g. "%m/%d/%Y"). + # Stored in column_mappings so it doesn't conflict with date_format, which is + # always "%Y-%m-%d" because QIF rows store dates in ISO 8601 after parsing. + def qif_date_format + column_mappings&.dig("qif_date_format") || "%m/%d/%Y" + end + + def qif_date_format=(fmt) + self.column_mappings = (column_mappings || {}).merge("qif_date_format" => fmt) + end + + # Parses the stored QIF content and creates Import::Row records. + # Overrides the base CSV-based method with QIF-specific parsing. + # + # On first run (qif_date_format not yet set), auto-detects the date format + # from the QIF file's D-field samples. + def generate_rows_from_csv + detect_and_set_qif_date_format! unless column_mappings&.key?("qif_date_format") + + rows.destroy_all + + if investment_account? + generate_investment_rows + else + generate_transaction_rows + end + + update_column(:rows_count, rows.count) + end + + def import! + transaction do + mappings.each(&:create_mappable!) + + if investment_account? + import_investment_rows! + else + import_transaction_rows! + + if (ob = QifParser.parse_opening_balance(raw_file_str, date_format: qif_date_format)) + Account::OpeningBalanceManager.new(account).set_opening_balance( + balance: ob[:amount], + date: ob[:date] + ) + else + adjust_opening_anchor_if_needed! + end + end + end + end + + # QIF has a fixed format – no CSV column mapping step needed. + def requires_csv_workflow? + false + end + + def rows_ordered + rows.order(date: :desc, id: :desc) + end + + def column_keys + if qif_account_type == "Invst" + %i[date ticker qty price amount currency name] + else + %i[date amount name currency category tags notes] + end + end + + def publishable? + account.present? && super + end + + # Returns true if import! will move the opening anchor back to cover transactions + # that predate the current anchor date. Used to show a notice in the confirm step. + def will_adjust_opening_anchor? + return false if investment_account? + return false if QifParser.parse_opening_balance(raw_file_str, date_format: qif_date_format).present? + return false unless account.present? + + manager = Account::OpeningBalanceManager.new(account) + return false unless manager.has_opening_anchor? + + earliest = earliest_row_date + earliest.present? && earliest < manager.opening_date + end + + # The date the opening anchor will be moved to when will_adjust_opening_anchor? is true. + def adjusted_opening_anchor_date + earliest = earliest_row_date + (earliest - 1.day) if earliest.present? + end + + # The account type declared in the QIF file (e.g. "CCard", "Bank", "Invst"). + def qif_account_type + return @qif_account_type if instance_variable_defined?(:@qif_account_type) + @qif_account_type = raw_file_str.present? ? QifParser.account_type(raw_file_str) : nil + end + + # Unique categories used across all rows (blank entries excluded). + def row_categories + rows.distinct.pluck(:category).reject(&:blank?).sort + end + + # Returns true if the QIF file contains any split transactions. + def has_split_transactions? + return @has_split_transactions if defined?(@has_split_transactions) + @has_split_transactions = parsed_transactions_with_splits.any?(&:split) + end + + # Categories that appear on split transactions in the QIF file. + # Split transactions use S/$ fields to break a total into sub-amounts; + # the app does not yet support splits, so these categories are flagged. + def split_categories + return @split_categories if defined?(@split_categories) + + split_cats = parsed_transactions_with_splits.select(&:split).map(&:category).reject(&:blank?).uniq.sort + @split_categories = split_cats & row_categories + end + + # Unique tags used across all rows (blank entries excluded). + def row_tags + rows.flat_map(&:tags_list).uniq.reject(&:blank?).sort + end + + # True once the category/tag selection step has been completed + # (sync_mappings has been called, which always produces at least one mapping). + def categories_selected? + mappings.any? + end + + def mapping_steps + [ Import::CategoryMapping, Import::TagMapping ] + end + + # QIF dates need normalization (apostrophe → separator, 2-digit year expansion) + # before strptime can parse them, so we delegate to QifParser. + def raw_date_samples + QifParser.extract_raw_dates(raw_file_str) + end + + def try_parse_date_sample(sample, format:) + QifParser.try_parse_date(sample, date_format: format) + end + + private + + def parsed_transactions_with_splits + @parsed_transactions_with_splits ||= QifParser.parse(raw_file_str) + end + + def investment_account? + qif_account_type == "Invst" + end + + # ------------------------------------------------------------------ + # Row generation + # ------------------------------------------------------------------ + + def generate_transaction_rows + transactions = QifParser.parse(raw_file_str, date_format: qif_date_format) + + mapped_rows = transactions.map do |trn| + { + date: trn.date.to_s, + amount: trn.amount.to_s, + currency: default_currency.to_s, + name: (trn.payee.presence || default_row_name).to_s, + notes: trn.memo.to_s, + category: trn.category.to_s, + tags: trn.tags.join("|"), + account: "", + qty: "", + ticker: "", + price: "", + exchange_operating_mic: "", + entity_type: "" + } + end + + if mapped_rows.any? + rows.insert_all!(mapped_rows) + rows.reset + end + end + + def generate_investment_rows + inv_transactions = QifParser.parse_investment_transactions(raw_file_str, date_format: qif_date_format) + + mapped_rows = inv_transactions.map do |trn| + if QifParser::TRADE_ACTIONS.include?(trn.action) + qty = trade_qty_for(trn.action, trn.qty) + + { + date: trn.date.to_s, + ticker: trn.security_ticker.to_s, + qty: qty.to_s, + price: trn.price.to_s, + amount: trn.amount.to_s, + currency: default_currency.to_s, + name: trade_row_name(trn), + notes: trn.memo.to_s, + category: "", + tags: "", + account: "", + exchange_operating_mic: "", + entity_type: trn.action + } + else + { + date: trn.date.to_s, + amount: trn.amount.to_s, + currency: default_currency.to_s, + name: transaction_row_name(trn), + notes: trn.memo.to_s, + category: trn.category.to_s, + tags: trn.tags.join("|"), + account: "", + qty: "", + ticker: "", + price: "", + exchange_operating_mic: "", + entity_type: trn.action + } + end + end + + if mapped_rows.any? + rows.insert_all!(mapped_rows) + rows.reset + end + end + + # ------------------------------------------------------------------ + # Import execution + # ------------------------------------------------------------------ + + def import_transaction_rows! + transactions = rows.map do |row| + category = mappings.categories.mappable_for(row.category) + tags = row.tags_list.map { |tag| mappings.tags.mappable_for(tag) }.compact + + Transaction.new( + category: category, + tags: tags, + entry: Entry.new( + account: account, + date: row.date_iso, + amount: row.signed_amount, + name: row.name, + currency: row.currency, + notes: row.notes, + import: self, + import_locked: true + ) + ) + end + + Transaction.import!(transactions, recursive: true) + end + + def import_investment_rows! + trade_rows = rows.select { |r| QifParser::TRADE_ACTIONS.include?(r.entity_type) } + transaction_rows = rows.reject { |r| QifParser::TRADE_ACTIONS.include?(r.entity_type) } + + if trade_rows.any? + trades = trade_rows.map do |row| + security = find_or_create_security(ticker: row.ticker) + + # Use the stored T-field amount for accuracy (includes any fees/commissions). + # Buy-like actions are cash outflows (positive); sell-like are inflows (negative). + entry_amount = QifParser::BUY_LIKE_ACTIONS.include?(row.entity_type) ? row.amount.to_d : -row.amount.to_d + + Trade.new( + security: security, + qty: row.qty.to_d, + price: row.price.to_d, + currency: row.currency, + investment_activity_label: investment_activity_label_for(row.entity_type), + entry: Entry.new( + account: account, + date: row.date_iso, + amount: entry_amount, + name: row.name, + currency: row.currency, + import: self, + import_locked: true + ) + ) + end + + Trade.import!(trades, recursive: true) + end + + if transaction_rows.any? + transactions = transaction_rows.map do |row| + # Inflow actions: money entering account → negative Entry.amount + # Outflow actions: money leaving account → positive Entry.amount + entry_amount = QifParser::INFLOW_TRANSACTION_ACTIONS.include?(row.entity_type) ? -row.amount.to_d : row.amount.to_d + + category = mappings.categories.mappable_for(row.category) + tags = row.tags_list.map { |tag| mappings.tags.mappable_for(tag) }.compact + + Transaction.new( + category: category, + tags: tags, + entry: Entry.new( + account: account, + date: row.date_iso, + amount: entry_amount, + name: row.name, + currency: row.currency, + notes: row.notes, + import: self, + import_locked: true + ) + ) + end + + Transaction.import!(transactions, recursive: true) + end + end + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def adjust_opening_anchor_if_needed! + manager = Account::OpeningBalanceManager.new(account) + return unless manager.has_opening_anchor? + + earliest = earliest_row_date + return unless earliest.present? && earliest < manager.opening_date + + Account::OpeningBalanceManager.new(account).set_opening_balance( + balance: manager.opening_balance, + date: earliest - 1.day + ) + end + + def earliest_row_date + str = rows.minimum(:date) + Date.parse(str) if str.present? + end + + def set_default_config + update!( + signage_convention: "inflows_positive", + date_format: "%Y-%m-%d", + number_format: "1,234.56" + ) + end + + # Auto-detects the QIF file's date format from D-field samples and persists it. + # Falls back to "%m/%d/%Y" (US convention) if detection is inconclusive. + def detect_and_set_qif_date_format! + samples = QifParser.extract_raw_dates(raw_file_str) + detected = Import.detect_date_format(samples, fallback: "%m/%d/%Y") + self.qif_date_format = detected + update_column(:column_mappings, column_mappings) + end + + # Returns the signed qty for a trade row: + # buy-like actions keep qty positive; sell-like negate it. + def trade_qty_for(action, raw_qty) + qty = raw_qty.to_d + QifParser::SELL_LIKE_ACTIONS.include?(action) ? -qty : qty + end + + def investment_activity_label_for(action) + return nil if action.blank? + QifParser::BUY_LIKE_ACTIONS.include?(action) ? "Buy" : "Sell" + end + + def trade_row_name(trn) + type = QifParser::BUY_LIKE_ACTIONS.include?(trn.action) ? "buy" : "sell" + ticker = trn.security_ticker.presence || trn.security_name || "Unknown" + Trade.build_name(type, trn.qty.to_d.abs, ticker) + end + + def transaction_row_name(trn) + security = trn.security_name.presence + payee = trn.payee.presence + + case trn.action + when "Div" then payee || (security ? "Dividend: #{security}" : "Dividend") + when "IntInc" then payee || (security ? "Interest: #{security}" : "Interest") + when "XIn" then payee || "Cash Transfer In" + when "XOut" then payee || "Cash Transfer Out" + when "CGLong" then payee || (security ? "Capital Gain (Long): #{security}" : "Capital Gain (Long)") + when "CGShort" then payee || (security ? "Capital Gain (Short): #{security}" : "Capital Gain (Short)") + when "MiscInc" then payee || trn.memo.presence || "Miscellaneous Income" + when "MiscExp" then payee || trn.memo.presence || "Miscellaneous Expense" + else payee || trn.action + end + end + + def find_or_create_security(ticker: nil, exchange_operating_mic: nil) + return nil unless ticker.present? + + @security_cache ||= {} + + cache_key = [ ticker, exchange_operating_mic ].compact.join(":") + security = @security_cache[cache_key] + return security if security.present? + + security = Security::Resolver.new( + ticker, + exchange_operating_mic: exchange_operating_mic.presence + ).resolve + + @security_cache[cache_key] = security + security + end +end diff --git a/app/models/recurring_transaction.rb b/app/models/recurring_transaction.rb index bc8b2e8b3..eb2784ef5 100644 --- a/app/models/recurring_transaction.rb +++ b/app/models/recurring_transaction.rb @@ -2,6 +2,7 @@ class RecurringTransaction < ApplicationRecord include Monetizable belongs_to :family + belongs_to :account, optional: true belongs_to :merchant, optional: true monetize :amount @@ -35,6 +36,9 @@ class RecurringTransaction < ApplicationRecord scope :for_family, ->(family) { where(family: family) } scope :expected_soon, -> { active.where("next_expected_date <= ?", 1.month.from_now) } + scope :accessible_by, ->(user) { + where(account_id: Account.accessible_by(user).select(:id)).or(where(account_id: nil)) + } # Class methods for identification and cleanup # Schedules pattern identification with debounce to run after all syncs complete @@ -66,7 +70,8 @@ class RecurringTransaction < ApplicationRecord name: transaction.merchant_id.present? ? nil : entry.name, currency: entry.currency, expected_day: expected_day, - lookback_months: 6 + lookback_months: 6, + account: entry.account ) # Calculate amount variance from historical data @@ -89,6 +94,7 @@ class RecurringTransaction < ApplicationRecord create!( family: family, + account: entry.account, merchant_id: transaction.merchant_id, name: transaction.merchant_id.present? ? nil : entry.name, amount: entry.amount, @@ -106,10 +112,10 @@ class RecurringTransaction < ApplicationRecord end # Find matching transaction entries for variance calculation - def self.find_matching_transaction_entries(family:, merchant_id:, name:, currency:, expected_day:, lookback_months: 6) + def self.find_matching_transaction_entries(family:, merchant_id:, name:, currency:, expected_day:, lookback_months: 6, account: nil) lookback_date = lookback_months.months.ago.to_date - entries = family.entries + entries = (account.present? ? account.entries : family.entries) .where(entryable_type: "Transaction") .where(currency: currency) .where("entries.date >= ?", lookback_date) @@ -131,14 +137,15 @@ class RecurringTransaction < ApplicationRecord end # Find matching transaction amounts for variance calculation - def self.find_matching_transaction_amounts(family:, merchant_id:, name:, currency:, expected_day:, lookback_months: 6) + def self.find_matching_transaction_amounts(family:, merchant_id:, name:, currency:, expected_day:, lookback_months: 6, account: nil) matching_entries = find_matching_transaction_entries( family: family, merchant_id: merchant_id, name: name, currency: currency, expected_day: expected_day, - lookback_months: lookback_months + lookback_months: lookback_months, + account: account ) matching_entries.map(&:amount) @@ -173,8 +180,10 @@ class RecurringTransaction < ApplicationRecord def matching_transactions # For manual recurring with amount variance, match within range # For automatic recurring, match exact amount + base = account.present? ? account.entries : family.entries + entries = if manual? && has_amount_variance? - family.entries + base .where(entryable_type: "Transaction") .where(currency: currency) .where("entries.amount BETWEEN ? AND ?", expected_amount_min, expected_amount_max) @@ -183,7 +192,7 @@ class RecurringTransaction < ApplicationRecord [ expected_day_of_month + 2, 31 ].min) .order(date: :desc) else - family.entries + base .where(entryable_type: "Transaction") .where(currency: currency) .where("entries.amount = ?", amount) diff --git a/app/models/recurring_transaction/identifier.rb b/app/models/recurring_transaction/identifier.rb index 916b77483..82834c26a 100644 --- a/app/models/recurring_transaction/identifier.rb +++ b/app/models/recurring_transaction/identifier.rb @@ -24,12 +24,12 @@ class RecurringTransaction transaction = entry.entryable # Use merchant_id if present, otherwise use entry name identifier = transaction.merchant_id.present? ? [ :merchant, transaction.merchant_id ] : [ :name, entry.name ] - [ identifier, entry.amount.round(2), entry.currency ] + [ identifier, entry.amount.round(2), entry.currency, entry.account_id ] end recurring_patterns = [] - grouped_transactions.each do |(identifier, amount, currency), entries| + grouped_transactions.each do |(identifier, amount, currency, account_id), entries| next if entries.size < 3 # Must have at least 3 occurrences # Check if the last occurrence was within the last 45 days @@ -49,6 +49,7 @@ class RecurringTransaction pattern = { amount: amount, currency: currency, + account_id: account_id, expected_day_of_month: expected_day, last_occurrence_date: last_occurrence.date, occurrence_count: entries.size, @@ -70,7 +71,8 @@ class RecurringTransaction # Build find conditions based on whether it's merchant-based or name-based find_conditions = { amount: pattern[:amount], - currency: pattern[:currency] + currency: pattern[:currency], + account_id: pattern[:account_id] } if pattern[:merchant_id].present? @@ -148,7 +150,8 @@ class RecurringTransaction name: recurring.name, currency: recurring.currency, expected_day: recurring.expected_day_of_month, - lookback_months: 6 + lookback_months: 6, + account: recurring.account ) next if matching_entries.empty? @@ -180,7 +183,8 @@ class RecurringTransaction name: recurring_transaction.name, currency: recurring_transaction.currency, expected_day: recurring_transaction.expected_day_of_month, - lookback_months: 6 + lookback_months: 6, + account: recurring_transaction.account ) # Update if we have any matching transactions diff --git a/app/models/rule.rb b/app/models/rule.rb index 74dbaece8..b53f80f83 100644 --- a/app/models/rule.rb +++ b/app/models/rule.rb @@ -40,6 +40,19 @@ class Rule < ApplicationRecord matching_resources_scope.count end + # Creates a categorization rule for the Quick Categorize Wizard. + # Returns the saved rule, or nil if a duplicate or invalid rule already exists. + def self.create_from_grouping(family, grouping_key, category, transaction_type: nil) + rule = family.rules.build(name: grouping_key, resource_type: "transaction", active: true) + rule.conditions.build(condition_type: "transaction_name", operator: "like", value: grouping_key) + rule.conditions.build(condition_type: "transaction_type", operator: "=", value: transaction_type) if transaction_type.present? + rule.actions.build(action_type: "set_transaction_category", value: category.id.to_s) + rule.save! + rule + rescue ActiveRecord::RecordInvalid + nil + end + # Calculates total unique resources affected across multiple rules # This handles overlapping rules by deduplicating transaction IDs def self.total_affected_resource_count(rules) diff --git a/app/models/rule/condition_filter/transaction_account.rb b/app/models/rule/condition_filter/transaction_account.rb new file mode 100644 index 000000000..1ec94b515 --- /dev/null +++ b/app/models/rule/condition_filter/transaction_account.rb @@ -0,0 +1,14 @@ +class Rule::ConditionFilter::TransactionAccount < Rule::ConditionFilter + def type + "select" + end + + def options + family.accounts.accessible_by(Current.user).alphabetically.pluck(:name, :id) + end + + def apply(scope, operator, value) + expression = build_sanitized_where_condition("entries.account_id", operator, value) + scope.where(expression) + end +end diff --git a/app/models/rule/condition_filter/transaction_merchant.rb b/app/models/rule/condition_filter/transaction_merchant.rb index 581db1cdf..ae28abc91 100644 --- a/app/models/rule/condition_filter/transaction_merchant.rb +++ b/app/models/rule/condition_filter/transaction_merchant.rb @@ -4,7 +4,7 @@ class Rule::ConditionFilter::TransactionMerchant < Rule::ConditionFilter end def options - family.assigned_merchants.alphabetically.pluck(:name, :id) + family.available_merchants_for(Current.user).alphabetically.pluck(:name, :id) end def prepare(scope) diff --git a/app/models/rule/registry/transaction_resource.rb b/app/models/rule/registry/transaction_resource.rb index a9497d9c0..ee697927c 100644 --- a/app/models/rule/registry/transaction_resource.rb +++ b/app/models/rule/registry/transaction_resource.rb @@ -1,6 +1,6 @@ class Rule::Registry::TransactionResource < Rule::Registry def resource_scope - family.transactions.visible.with_entry.where(entry: { date: rule.effective_date.. }) + family.transactions.visible.with_entry.merge(Entry.excluding_split_parents).where(entry: { date: rule.effective_date.. }) end def condition_filters @@ -11,7 +11,8 @@ class Rule::Registry::TransactionResource < Rule::Registry Rule::ConditionFilter::TransactionMerchant.new(rule), Rule::ConditionFilter::TransactionCategory.new(rule), Rule::ConditionFilter::TransactionDetails.new(rule), - Rule::ConditionFilter::TransactionNotes.new(rule) + Rule::ConditionFilter::TransactionNotes.new(rule), + Rule::ConditionFilter::TransactionAccount.new(rule) ] end diff --git a/app/models/rule_import.rb b/app/models/rule_import.rb index d2a2d07ca..48ae775fc 100644 --- a/app/models/rule_import.rb +++ b/app/models/rule_import.rb @@ -212,7 +212,6 @@ class RuleImport < Import category = family.categories.create!( name: value, color: Category::UNCATEGORIZED_COLOR, - classification: "expense", lucide_icon: "shapes" ) end @@ -245,7 +244,6 @@ class RuleImport < Import category = family.categories.create!( name: value, color: Category::UNCATEGORIZED_COLOR, - classification: "expense", lucide_icon: "shapes" ) end @@ -291,9 +289,14 @@ class RuleImport < Import def parse_json_safely(json_string, field_name) return [] if json_string.blank? - # Clean up the JSON string - remove extra escaping that might come from CSV parsing cleaned = json_string.to_s.strip + # Most API-created rows already store valid JSON. Parse them as-is before + # falling back to the legacy cleanup path for older malformed payloads. + parse_json_payload(cleaned, normalize_legacy_strings: false) + rescue JSON::ParserError + # Clean up the JSON string - remove extra escaping that might come from CSV parsing + # Remove surrounding quotes if present (both single and double) cleaned = cleaned.gsub(/\A["']+|["']+\z/, "") @@ -323,8 +326,46 @@ class RuleImport < Import end # Try parsing - JSON.parse(cleaned) + parse_json_payload(cleaned, normalize_legacy_strings: true) rescue JSON::ParserError => e raise JSON::ParserError.new("Invalid JSON in #{field_name}: #{e.message}. Raw value: #{json_string.inspect}") end + + def parse_json_payload(payload, normalize_legacy_strings:) + parsed = JSON.parse(payload) + parsed = JSON.parse(parsed) if wrapped_json_payload?(parsed) + + normalize_json_values(parsed, normalize_legacy_strings:) + end + + def wrapped_json_payload?(value) + return false unless value.is_a?(String) + + stripped_value = value.strip + stripped_value.start_with?("[", "{") + end + + def normalize_json_values(value, normalize_legacy_strings:) + case value + when Array + value.map { |item| normalize_json_values(item, normalize_legacy_strings:) } + when Hash + value.transform_values { |item| normalize_json_values(item, normalize_legacy_strings:) } + when String + normalized = value + .gsub(/\\u([0-9a-fA-F]{4})/i) { [ $1.to_i(16) ].pack("U") } + .gsub('\\"', '"') + + if normalize_legacy_strings + normalized = normalized + .gsub("\\n", "\n") + .gsub("\\r", "\r") + .gsub("\\t", "\t") + end + + normalized + else + value + end + end end diff --git a/app/models/security.rb b/app/models/security.rb index fb26d4d3e..35b0e8bdd 100644 --- a/app/models/security.rb +++ b/app/models/security.rb @@ -6,6 +6,8 @@ class Security < ApplicationRecord # Data stored in config/exchanges.yml EXCHANGES = YAML.safe_load_file(Rails.root.join("config", "exchanges.yml")).freeze + KINDS = %w[standard cash].freeze + before_validation :upcase_symbols before_save :generate_logo_url_from_brandfetch, if: :should_generate_logo? @@ -14,8 +16,24 @@ class Security < ApplicationRecord validates :ticker, presence: true validates :ticker, uniqueness: { scope: :exchange_operating_mic, case_sensitive: false } + validates :kind, inclusion: { in: KINDS } scope :online, -> { where(offline: false) } + scope :standard, -> { where(kind: "standard") } + + # Lazily finds or creates a synthetic cash security for an account. + # Used as fallback when creating an interest Trade without a user-selected security. + def self.cash_for(account) + ticker = "CASH-#{account.id}".upcase + find_or_create_by!(ticker: ticker, kind: "cash") do |s| + s.name = "Cash" + s.offline = true + end + end + + def cash? + kind == "cash" + end # Returns user-friendly exchange name for a MIC code def self.exchange_name_for(mic) @@ -73,6 +91,7 @@ class Security < ApplicationRecord end def should_generate_logo? + return false if cash? url = brandfetch_icon_url return false unless url.present? diff --git a/app/models/security/health_checker.rb b/app/models/security/health_checker.rb index 92ff5f2ee..74e5a8d50 100644 --- a/app/models/security/health_checker.rb +++ b/app/models/security/health_checker.rb @@ -30,15 +30,15 @@ class Security::HealthChecker private # If a security has never had a health check, we prioritize it, regardless of batch size def never_checked_scope - Security.where(last_health_check_at: nil) + Security.standard.where(last_health_check_at: nil) end # Any securities not checked for 30 days are due # We only process the batch size, which means some "due" securities will not be checked today # This is by design, to prevent all securities from coming due at the same time def due_for_check_scope - Security.where(last_health_check_at: ..HEALTH_CHECK_INTERVAL.ago) - .order(last_health_check_at: :asc) + Security.standard.where(last_health_check_at: ..HEALTH_CHECK_INTERVAL.ago) + .order(last_health_check_at: :asc) end end diff --git a/app/models/security/price/importer.rb b/app/models/security/price/importer.rb index bc5840c0c..9d57332b6 100644 --- a/app/models/security/price/importer.rb +++ b/app/models/security/price/importer.rb @@ -4,6 +4,8 @@ class Security::Price::Importer PROVISIONAL_LOOKBACK_DAYS = 7 + attr_reader :provider_error + def initialize(security:, security_provider:, start_date:, end_date:, clear_cache: false) @security = security @security_provider = security_provider @@ -130,6 +132,7 @@ class Security::Price::Importer scope.set_context("security", { id: security.id, start_date: start_date, end_date: end_date }) end + @provider_error = error_message {} end end diff --git a/app/models/security/provided.rb b/app/models/security/provided.rb index b80046483..e412244a9 100644 --- a/app/models/security/provided.rb +++ b/app/models/security/provided.rb @@ -114,13 +114,14 @@ module Security::Provided return 0 end - Security::Price::Importer.new( + importer = Security::Price::Importer.new( security: self, security_provider: provider, start_date: start_date, end_date: end_date, clear_cache: clear_cache - ).import_provider_prices + ) + [ importer.import_provider_prices, importer.provider_error ] end private diff --git a/app/models/setting.rb b/app/models/setting.rb index 9a9facfb8..b62d4073d 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -10,6 +10,9 @@ class Setting < RailsSettings::Base field :openai_uri_base, type: :string, default: ENV["OPENAI_URI_BASE"] field :openai_model, type: :string, default: ENV["OPENAI_MODEL"] field :openai_json_mode, type: :string, default: ENV["LLM_JSON_MODE"] + field :external_assistant_url, type: :string + field :external_assistant_token, type: :string + field :external_assistant_agent_id, type: :string field :brand_fetch_client_id, type: :string, default: ENV["BRAND_FETCH_CLIENT_ID"] field :brand_fetch_high_res_logos, type: :boolean, default: ENV.fetch("BRAND_FETCH_HIGH_RES_LOGOS", "false") == "true" @@ -70,6 +73,7 @@ class Setting < RailsSettings::Base field :onboarding_state, type: :string, default: DEFAULT_ONBOARDING_STATE field :require_invite_for_signup, type: :boolean, default: false field :require_email_confirmation, type: :boolean, default: ENV.fetch("REQUIRE_EMAIL_CONFIRMATION", "true") == "true" + field :invite_only_default_family_id, type: :string, default: nil def self.validate_onboarding_state!(state) return if ONBOARDING_STATES.include?(state) diff --git a/app/models/simplefin_account/investments/holdings_processor.rb b/app/models/simplefin_account/investments/holdings_processor.rb index 856b2c69d..3274b4b9d 100644 --- a/app/models/simplefin_account/investments/holdings_processor.rb +++ b/app/models/simplefin_account/investments/holdings_processor.rb @@ -42,9 +42,12 @@ class SimplefinAccount::Investments::HoldingsProcessor end # Parse provider data with robust fallbacks across SimpleFin sources + # NOTE: "value" is intentionally excluded from the market_value fallback chain + # because some brokerages (e.g. Vanguard, Fidelity) use "value" to mean cost basis, + # which would cause the system to display average cost as current price. (GH #1182) qty = parse_decimal(any_of(simplefin_holding, %w[shares quantity qty units])) - market_value = parse_decimal(any_of(simplefin_holding, %w[market_value value current_value])) - cost_basis = parse_decimal(any_of(simplefin_holding, %w[cost_basis basis total_cost])) + market_value = parse_decimal(any_of(simplefin_holding, %w[market_value current_value])) + cost_basis = parse_decimal(any_of(simplefin_holding, %w[cost_basis basis total_cost value])) # Derive price from market_value when possible; otherwise fall back to any price field fallback_price = parse_decimal(any_of(simplefin_holding, %w[purchase_price price unit_price average_cost avg_cost])) diff --git a/app/models/simplefin_account/processor.rb b/app/models/simplefin_account/processor.rb index b8248c707..f9ef87641 100644 --- a/app/models/simplefin_account/processor.rb +++ b/app/models/simplefin_account/processor.rb @@ -64,12 +64,17 @@ class SimplefinAccount::Processor institution: org[:name] ) rescue nil is_mapper_liability = inferred && [ "CreditCard", "Loan" ].include?(inferred.accountable_type) - is_liability = is_linked_liability || is_mapper_liability + is_liability = + if account.accountable_type.present? + is_linked_liability + else + is_mapper_liability + end if is_mapper_liability && !is_linked_liability Rails.logger.warn( - "SimpleFIN liability normalization: linked account #{account.id} type=#{account.accountable_type} " \ - "appears to be liability via mapper (#{inferred.accountable_type}). Normalizing as liability; consider relinking." + "SimpleFIN account type mismatch: linked account #{account.id} type=#{account.accountable_type} " \ + "differs from mapper inference (#{inferred.accountable_type}). Using linked account type." ) end diff --git a/app/models/simplefin_item/syncer.rb b/app/models/simplefin_item/syncer.rb index e8276a895..650ce4b69 100644 --- a/app/models/simplefin_item/syncer.rb +++ b/app/models/simplefin_item/syncer.rb @@ -172,28 +172,10 @@ class SimplefinItem::Syncer target_id = ActionView::RecordIdentifier.dom_id(simplefin_item) Turbo::StreamsChannel.broadcast_replace_to(simplefin_item.family, target: target_id, html: card_html) - # Also refresh the Manual Accounts group so duplicates clear without a full page reload - begin - manual_accounts = simplefin_item.family.accounts - .visible_manual - .order(:name) - if manual_accounts.any? - manual_html = ApplicationController.render( - partial: "accounts/index/manual_accounts", - formats: [ :html ], - locals: { accounts: manual_accounts } - ) - Turbo::StreamsChannel.broadcast_update_to(simplefin_item.family, target: "manual-accounts", html: manual_html) - else - manual_html = ApplicationController.render(inline: '
') - Turbo::StreamsChannel.broadcast_replace_to(simplefin_item.family, target: "manual-accounts", html: manual_html) - end - rescue => inner - Rails.logger.warn("SimplefinItem::Syncer manual-accounts broadcast failed: #{inner.class} - #{inner.message}") - end - - # Intentionally do not broadcast modal reloads here to avoid unexpected auto-pop after sync. - # Modal opening is controlled explicitly via controller redirects with actionable conditions. + # Broadcast a refresh signal instead of rendered HTML. Each user's browser + # re-fetches via their own authenticated request, so the manual accounts + # list is correctly scoped to the current user. + simplefin_item.family.broadcast_refresh rescue => e Rails.logger.warn("SimplefinItem::Syncer broadcast failed: #{e.class} - #{e.message}") end diff --git a/app/models/snaptrade_account.rb b/app/models/snaptrade_account.rb index 40ceb4f9f..ddb7a4b63 100644 --- a/app/models/snaptrade_account.rb +++ b/app/models/snaptrade_account.rb @@ -17,6 +17,7 @@ class SnaptradeAccount < ApplicationRecord has_one :linked_account, through: :account_provider, source: :account validates :name, :currency, presence: true + validates :snaptrade_account_id, uniqueness: { scope: :snaptrade_item_id, allow_nil: true } # Enqueue cleanup job after destruction to avoid blocking transaction with API call after_destroy :enqueue_connection_cleanup diff --git a/app/models/sure_import.rb b/app/models/sure_import.rb new file mode 100644 index 000000000..05d927c4d --- /dev/null +++ b/app/models/sure_import.rb @@ -0,0 +1,132 @@ +class SureImport < Import + MAX_NDJSON_SIZE = 10.megabytes + ALLOWED_NDJSON_CONTENT_TYPES = %w[ + application/x-ndjson + application/ndjson + application/json + application/octet-stream + text/plain + ].freeze + + has_one_attached :ndjson_file, dependent: :purge_later + + class << self + # Counts JSON lines by top-level "type" (used for dry-run summaries and row limits). + def ndjson_line_type_counts(content) + return {} unless content.present? + + counts = Hash.new(0) + content.each_line do |line| + next if line.strip.empty? + + begin + record = JSON.parse(line) + counts[record["type"]] += 1 if record["type"] + rescue JSON::ParserError + # Skip invalid lines + end + end + counts + end + + def dry_run_totals_from_ndjson(content) + counts = ndjson_line_type_counts(content) + { + accounts: counts["Account"] || 0, + categories: counts["Category"] || 0, + tags: counts["Tag"] || 0, + merchants: counts["Merchant"] || 0, + transactions: counts["Transaction"] || 0, + trades: counts["Trade"] || 0, + valuations: counts["Valuation"] || 0, + budgets: counts["Budget"] || 0, + budget_categories: counts["BudgetCategory"] || 0, + rules: counts["Rule"] || 0 + } + end + + def valid_ndjson_first_line?(str) + return false if str.blank? + + first_line = str.lines.first&.strip + return false if first_line.blank? + + begin + record = JSON.parse(first_line) + record.key?("type") && record.key?("data") + rescue JSON::ParserError + false + end + end + end + + def requires_csv_workflow? + false + end + + def column_keys + [] + end + + def required_column_keys + [] + end + + def mapping_steps + [] + end + + def csv_template + nil + end + + def dry_run + return {} unless uploaded? + + self.class.dry_run_totals_from_ndjson(ndjson_blob_string) + end + + def import! + importer = Family::DataImporter.new(family, ndjson_blob_string) + result = importer.import! + + result[:accounts].each { |account| accounts << account } + result[:entries].each { |entry| entries << entry } + end + + def uploaded? + return false unless ndjson_file.attached? + + self.class.valid_ndjson_first_line?(ndjson_blob_string) + end + + def configured? + uploaded? + end + + def cleaned? + configured? + end + + def publishable? + cleaned? && dry_run.values.sum.positive? + end + + def max_row_count + 100_000 + end + + # Row total for max-row enforcement (counts every parsed line with a "type", including unsupported types). + def sync_ndjson_rows_count! + return unless ndjson_file.attached? + + total = self.class.ndjson_line_type_counts(ndjson_blob_string).values.sum + update_column(:rows_count, total) + end + + private + + def ndjson_blob_string + ndjson_file.download.force_encoding(Encoding::UTF_8) + end +end diff --git a/app/models/tag.rb b/app/models/tag.rb index c5bdc0bc2..108e1c89c 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -5,6 +5,7 @@ class Tag < ApplicationRecord has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping" validates :name, presence: true, uniqueness: { scope: :family } + validates :color, format: { with: /\A#[0-9A-Fa-f]{6}\z/ }, allow_nil: true scope :alphabetically, -> { order(:name) } diff --git a/app/models/trade.rb b/app/models/trade.rb index 10159d201..dbdb90f31 100644 --- a/app/models/trade.rb +++ b/app/models/trade.rb @@ -2,6 +2,7 @@ class Trade < ApplicationRecord include Entryable, Monetizable monetize :price + monetize :fee belongs_to :security belongs_to :category, optional: true @@ -30,7 +31,7 @@ class Trade < ApplicationRecord end def unrealized_gain_loss - return nil if qty.negative? + return nil unless qty.positive? current_price = security.current_price return nil if current_price.nil? diff --git a/app/models/trade/create_form.rb b/app/models/trade/create_form.rb index d822131c5..54c78111e 100644 --- a/app/models/trade/create_form.rb +++ b/app/models/trade/create_form.rb @@ -2,7 +2,7 @@ class Trade::CreateForm include ActiveModel::Model attr_accessor :account, :date, :amount, :currency, :qty, - :price, :ticker, :manual_ticker, :type, :transfer_account_id + :price, :fee, :ticker, :manual_ticker, :type, :transfer_account_id # Either creates a trade, transaction, or transfer based on type # Returns the model, regardless of success or failure @@ -10,6 +10,8 @@ class Trade::CreateForm case type when "buy", "sell" create_trade + when "dividend" + create_dividend_income when "interest" create_interest_income when "deposit", "withdrawal" @@ -28,9 +30,13 @@ class Trade::CreateForm ).resolve end + def ticker_present? + ticker.present? || manual_ticker.present? + end + def create_trade signed_qty = type == "sell" ? -qty.to_d : qty.to_d - signed_amount = signed_qty * price.to_d + signed_amount = signed_qty * price.to_d + fee.to_d trade_entry = account.entries.new( name: Trade.build_name(type, qty, security.ticker), @@ -40,6 +46,7 @@ class Trade::CreateForm entryable: Trade.new( qty: signed_qty, price: price, + fee: fee.to_d, currency: currency, security: security, investment_activity_label: type.capitalize # "buy" → "Buy", "sell" → "Sell" @@ -54,15 +61,47 @@ class Trade::CreateForm trade_entry end - def create_interest_income - signed_amount = amount.to_d * -1 + # Dividends are always a Trade. Security is required. + def create_dividend_income + unless ticker_present? + entry = account.entries.build(entryable: Trade.new) + entry.errors.add(:base, I18n.t("trades.form.dividend_requires_security")) + return entry + end + begin + sec = security + create_income_trade(sec: sec, label: "Dividend", name: "Dividend: #{sec.ticker}") + rescue => e + Rails.logger.warn("Dividend security resolution failed: #{e.class} - #{e.message}") + entry = account.entries.build(entryable: Trade.new) + entry.errors.add(:base, I18n.t("trades.form.dividend_requires_security")) + entry + end + end + + # Interest in an investment account is always a Trade. + # Falls back to a synthetic cash security when none is selected. + def create_interest_income + sec = ticker_present? ? security : Security.cash_for(account) + name = sec.cash? ? "Interest" : "Interest: #{sec.ticker}" + create_income_trade(sec: sec, label: "Interest", name: name) + end + + def create_income_trade(sec:, label:, name:) entry = account.entries.build( - name: "Interest payment", + name: name, date: date, - amount: signed_amount, + amount: amount.to_d * -1, currency: currency, - entryable: Transaction.new + entryable: Trade.new( + qty: 0, + price: 0, + fee: 0, + currency: currency, + security: sec, + investment_activity_label: label + ) ) if entry.save diff --git a/app/models/transaction.rb b/app/models/transaction.rb index 397703034..75e36bbdc 100644 --- a/app/models/transaction.rb +++ b/app/models/transaction.rb @@ -1,5 +1,5 @@ class Transaction < ApplicationRecord - include Entryable, Transferable, Ruleable + include Entryable, Transferable, Ruleable, Splittable belongs_to :category, optional: true belongs_to :merchant, optional: true @@ -7,6 +7,23 @@ class Transaction < ApplicationRecord has_many :taggings, as: :taggable, dependent: :destroy has_many :tags, through: :taggings + # File attachments (receipts, invoices, etc.) using Active Storage + # Supports images (JPEG, PNG, GIF, WebP) and PDFs up to 10MB each + # Maximum 10 attachments per transaction, family-scoped access + has_many_attached :attachments do |attachable| + attachable.variant :thumbnail, resize_to_limit: [ 150, 150 ] + end + + # Attachment validation constants + MAX_ATTACHMENTS_PER_TRANSACTION = 10 + MAX_ATTACHMENT_SIZE = 10.megabytes + ALLOWED_CONTENT_TYPES = %w[ + image/jpeg image/jpg image/png image/gif image/webp + application/pdf + ].freeze + + validate :validate_attachments, if: -> { attachments.attached? } + accepts_nested_attributes_for :taggings, allow_destroy: true after_save :clear_merchant_unlinked_association, if: :merchant_id_previously_changed? @@ -38,22 +55,19 @@ class Transaction < ApplicationRecord # Internal movement labels that should be excluded from budget (auto cash management) INTERNAL_MOVEMENT_LABELS = [ "Transfer", "Sweep In", "Sweep Out", "Exchange" ].freeze + # Providers that support pending transaction flags + PENDING_PROVIDERS = %w[simplefin plaid lunchflow].freeze + # Pending transaction scopes - filter based on provider pending flags in extra JSONB # Works with any provider that stores pending status in extra["provider_name"]["pending"] scope :pending, -> { - where(<<~SQL.squish) - (transactions.extra -> 'simplefin' ->> 'pending')::boolean = true - OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true - OR (transactions.extra -> 'lunchflow' ->> 'pending')::boolean = true - SQL + conditions = PENDING_PROVIDERS.map { |provider| "(transactions.extra -> '#{provider}' ->> 'pending')::boolean = true" } + where(conditions.join(" OR ")) } scope :excluding_pending, -> { - where(<<~SQL.squish) - (transactions.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true - AND (transactions.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true - AND (transactions.extra -> 'lunchflow' ->> 'pending')::boolean IS DISTINCT FROM true - SQL + conditions = PENDING_PROVIDERS.map { |provider| "(transactions.extra -> '#{provider}' ->> 'pending')::boolean IS DISTINCT FROM true" } + where(conditions.join(" AND ")) } # Family-scoped query for Enrichable#clear_ai_cache @@ -78,9 +92,9 @@ class Transaction < ApplicationRecord def pending? extra_data = extra.is_a?(Hash) ? extra : {} - ActiveModel::Type::Boolean.new.cast(extra_data.dig("simplefin", "pending")) || - ActiveModel::Type::Boolean.new.cast(extra_data.dig("plaid", "pending")) || - ActiveModel::Type::Boolean.new.cast(extra_data.dig("lunchflow", "pending")) + PENDING_PROVIDERS.any? do |provider| + ActiveModel::Type::Boolean.new.cast(extra_data.dig(provider, "pending")) + end rescue false end @@ -157,8 +171,50 @@ class Transaction < ApplicationRecord true end + # Find potential posted transactions that might be duplicates of this pending transaction + # Returns entries (not transactions) for UI consistency with transfer matcher + # Lists recent posted transactions from the same account for manual merging + def pending_duplicate_candidates(limit: 20, offset: 0) + return Entry.none unless pending? && entry.present? + + account = entry.account + currency = entry.currency + + # Find recent posted transactions from the same account + conditions = PENDING_PROVIDERS.map { |provider| "(transactions.extra -> '#{provider}' ->> 'pending')::boolean IS NOT TRUE" } + + account.entries + .joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'") + .where.not(id: entry.id) + .where(currency: currency) + .where(conditions.join(" AND ")) + .order(date: :desc, created_at: :desc) + .limit(limit) + .offset(offset) + end + private + def validate_attachments + # Check attachment count limit + if attachments.size > MAX_ATTACHMENTS_PER_TRANSACTION + errors.add(:attachments, :too_many, max: MAX_ATTACHMENTS_PER_TRANSACTION) + end + + # Validate each attachment + attachments.each_with_index do |attachment, index| + # Check file size + if attachment.byte_size > MAX_ATTACHMENT_SIZE + errors.add(:attachments, :too_large, index: index + 1, max_mb: MAX_ATTACHMENT_SIZE / 1.megabyte) + end + + # Check content type + unless ALLOWED_CONTENT_TYPES.include?(attachment.content_type) + errors.add(:attachments, :invalid_format, index: index + 1, file_format: attachment.content_type) + end + end + end + def potential_posted_match_data return nil unless extra.is_a?(Hash) extra["potential_posted_match"] diff --git a/app/models/transaction/grouper.rb b/app/models/transaction/grouper.rb new file mode 100644 index 000000000..e55ac2573 --- /dev/null +++ b/app/models/transaction/grouper.rb @@ -0,0 +1,17 @@ +class Transaction::Grouper + Group = Data.define(:grouping_key, :display_name, :entries, :merchant, :transaction_type) + + # Returns the active grouping strategy class. + # Change this method to swap algorithms without touching the wizard. + def self.strategy + Transaction::Grouper::ByMerchantOrName + end + + # @param entries [ActiveRecord::Relation] pre-scoped entries to group (caller controls authorization) + # @param limit [Integer] max number of groups to return + # @param offset [Integer] number of groups to skip (for pagination) + # @return [Array] + def self.call(entries, limit: 20, offset: 0) + raise NotImplementedError, "#{name} must implement .call" + end +end diff --git a/app/models/transaction/grouper/by_merchant_or_name.rb b/app/models/transaction/grouper/by_merchant_or_name.rb new file mode 100644 index 000000000..8a80a8388 --- /dev/null +++ b/app/models/transaction/grouper/by_merchant_or_name.rb @@ -0,0 +1,48 @@ +class Transaction::Grouper::ByMerchantOrName < Transaction::Grouper + def self.call(entries, limit: 20, offset: 0) + new(entries).call(limit: limit, offset: offset) + end + + def initialize(entries) + @entries = entries + end + + def call(limit: 20, offset: 0) + uncategorized_entries + .group_by { |entry| grouping_key_for(entry) } + .map { |key, entries| build_group(key, entries) } + .sort_by { |g| [ -g.entries.size, g.display_name ] } + .drop([ offset, 0 ].max) + .first(limit) + end + + private + + attr_reader :entries + + def uncategorized_entries + entries + .uncategorized_transactions + .includes(entryable: :merchant) + .order(entries: { date: :desc }) + end + + def grouping_key_for(entry) + name = entry.entryable.merchant&.name.presence || entry.name + type = entry.amount.negative? ? "income" : "expense" + [ name, type ] + end + + def build_group(key, entries) + name, type = key + merchant = entries.find { |e| e.entryable.merchant.present? }&.entryable&.merchant + + Transaction::Grouper::Group.new( + grouping_key: name, + display_name: name, + entries: entries, + merchant: merchant, + transaction_type: type + ) + end +end diff --git a/app/models/transaction/search.rb b/app/models/transaction/search.rb index 3ea2a2391..d60e39fb4 100644 --- a/app/models/transaction/search.rb +++ b/app/models/transaction/search.rb @@ -16,17 +16,21 @@ class Transaction::Search attribute :tags, array: true attribute :active_accounts_only, :boolean, default: true - attr_reader :family + attr_reader :family, :accessible_account_ids - def initialize(family, filters: {}) + def initialize(family, filters: {}, accessible_account_ids: nil) @family = family + @accessible_account_ids = accessible_account_ids super(filters) end def transactions_scope @transactions_scope ||= begin # This already joins entries + accounts. To avoid expensive double-joins, don't join them again (causes full table scan) - query = family.transactions + query = family.transactions.merge(Entry.excluding_split_parents) + + # Scope to accessible accounts when provided + query = query.where(entries: { account_id: accessible_account_ids }) if accessible_account_ids query = apply_active_accounts_filter(query, active_accounts_only) query = apply_category_filter(query, categories) @@ -48,7 +52,7 @@ class Transaction::Search # because those transactions are retirement savings, not daily income/expenses. def totals @totals ||= begin - Rails.cache.fetch("transaction_search_totals/#{cache_key_base}") do + Rails.cache.fetch("transaction_search_totals/v2/#{cache_key_base}") do scope = transactions_scope # Exclude tax-advantaged accounts from totals calculation @@ -65,6 +69,14 @@ class Transaction::Search "COALESCE(SUM(CASE WHEN entries.amount < 0 AND transactions.kind NOT IN (?) THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as income_total", Transaction::TRANSFER_KINDS ]), + ActiveRecord::Base.sanitize_sql_array([ + "COALESCE(SUM(CASE WHEN entries.amount < 0 AND transactions.kind IN (?) THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as transfer_inflow_total", + Transaction::TRANSFER_KINDS + ]), + ActiveRecord::Base.sanitize_sql_array([ + "COALESCE(SUM(CASE WHEN entries.amount >= 0 AND transactions.kind IN (?) THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as transfer_outflow_total", + Transaction::TRANSFER_KINDS + ]), "COUNT(entries.id) as transactions_count" ) .joins( @@ -78,7 +90,9 @@ class Transaction::Search Totals.new( count: result.transactions_count.to_i, income_money: Money.new(result.income_total, family.currency), - expense_money: Money.new(result.expense_total, family.currency) + expense_money: Money.new(result.expense_total, family.currency), + transfer_inflow_money: Money.new(result.transfer_inflow_total, family.currency), + transfer_outflow_money: Money.new(result.transfer_outflow_total, family.currency) ) end end @@ -89,12 +103,13 @@ class Transaction::Search family.id, Digest::SHA256.hexdigest(attributes.sort.to_h.to_json), # cached by filters family.entries_cache_version, - Digest::SHA256.hexdigest(family.tax_advantaged_account_ids.sort.to_json) # stable across processes + Digest::SHA256.hexdigest(family.tax_advantaged_account_ids.sort.to_json), # stable across processes + accessible_account_ids ? Digest::SHA256.hexdigest(accessible_account_ids.sort.to_json) : "all" ].join("/") end private - Totals = Data.define(:count, :income_money, :expense_money) + Totals = Data.define(:count, :income_money, :expense_money, :transfer_inflow_money, :transfer_outflow_money) def apply_active_accounts_filter(query, active_accounts_only_filter) if active_accounts_only_filter diff --git a/app/models/transaction/splittable.rb b/app/models/transaction/splittable.rb new file mode 100644 index 000000000..032fd6624 --- /dev/null +++ b/app/models/transaction/splittable.rb @@ -0,0 +1,7 @@ +module Transaction::Splittable + extend ActiveSupport::Concern + + def splittable? + !transfer? && !entry.split_child? && !entry.split_parent? && !pending? && !entry.excluded? + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 5aef7afeb..02320aa8d 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -24,6 +24,7 @@ class User < ApplicationRecord belongs_to :family belongs_to :last_viewed_chat, class_name: "Chat", optional: true + belongs_to :default_account, class_name: "Account", optional: true has_many :sessions, dependent: :destroy has_many :chats, dependent: :destroy has_many :api_keys, dependent: :destroy @@ -33,6 +34,9 @@ class User < ApplicationRecord has_many :impersonated_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonated_id, dependent: :destroy has_many :oidc_identities, dependent: :destroy has_many :sso_audit_logs, dependent: :nullify + has_many :owned_accounts, class_name: "Account", foreign_key: :owner_id + has_many :account_shares, dependent: :destroy + has_many :shared_accounts, through: :account_shares, source: :account accepts_nested_attributes_for :family, update_only: true validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } @@ -115,6 +119,14 @@ class User < ApplicationRecord super_admin? || role == "admin" end + def accessible_accounts + family.accounts.accessible_by(self) + end + + def finance_accounts + family.accounts.included_in_finances_for(self) + end + def display_name [ first_name, last_name ].compact.join(" ").presence || email end @@ -136,7 +148,16 @@ class User < ApplicationRecord end def ai_available? - !Rails.application.config.app_mode.self_hosted? || ENV["OPENAI_ACCESS_TOKEN"].present? || Setting.openai_access_token.present? + return true unless Rails.application.config.app_mode.self_hosted? + + effective_type = ENV["ASSISTANT_TYPE"].presence || family&.assistant_type.presence || "builtin" + + case effective_type + when "external" + Assistant::External.available_for?(self) + else + ENV["OPENAI_ACCESS_TOKEN"].present? || Setting.openai_access_token.present? + end end def ai_enabled? @@ -184,6 +205,7 @@ class User < ApplicationRecord if last_user_in_family? family.destroy else + reassign_owned_accounts! destroy end end @@ -235,6 +257,15 @@ class User < ApplicationRecord AccountOrder.find(default_account_order) || AccountOrder.default end + def default_account_for_transactions + return nil unless default_account_id.present? + + account = default_account + return nil unless account&.eligible_for_transaction_default? && account.family_id == family_id + + account + end + # Dashboard preferences management def dashboard_section_collapsed?(section_key) preferences&.dig("collapsed_sections", section_key) == true @@ -297,6 +328,14 @@ class User < ApplicationRecord preferences&.dig("transactions_collapsed_sections", section_key) == true end + def show_split_grouped? + preferences&.dig("show_split_grouped") != false + end + + def dashboard_two_column? + preferences&.dig("dashboard_two_column") == true + end + def update_transactions_preferences(prefs) transaction do lock! @@ -373,6 +412,22 @@ class User < ApplicationRecord family.users.count == 1 end + def reassign_owned_accounts! + account_ids = owned_accounts.pluck(:id) + return if account_ids.empty? + + new_owner = family.users.where.not(id: id) + .find_by(role: %w[admin super_admin]) || + family.users.where.not(id: id) + .order(:created_at).first + + return unless new_owner + + Account.where(id: account_ids).update_all(owner_id: new_owner.id) + # Remove shares the new owner had for these accounts (they now own them) + AccountShare.where(account_id: account_ids, user_id: new_owner.id).delete_all + end + def deactivated_email email.gsub(/@/, "-deactivated-#{SecureRandom.uuid}@") end diff --git a/app/models/vector_store/embeddable.rb b/app/models/vector_store/embeddable.rb new file mode 100644 index 000000000..766b417f8 --- /dev/null +++ b/app/models/vector_store/embeddable.rb @@ -0,0 +1,152 @@ +module VectorStore::Embeddable + extend ActiveSupport::Concern + + CHUNK_SIZE = 2000 + CHUNK_OVERLAP = 200 + EMBED_BATCH_SIZE = 50 + + TEXT_EXTENSIONS = %w[ + .txt .md .csv .json .xml .html .css + .js .ts .py .rb .go .java .php .c .cpp .sh .tex + ].freeze + + private + + # Dispatch by extension: PDF via PDF::Reader, plain-text types as-is. + # Returns nil for unsupported binary formats. + def extract_text(file_content, filename) + ext = File.extname(filename).downcase + + case ext + when ".pdf" + extract_pdf_text(file_content) + when *TEXT_EXTENSIONS + file_content.to_s.encode("UTF-8", invalid: :replace, undef: :replace) + else + nil + end + end + + def extract_pdf_text(file_content) + io = StringIO.new(file_content) + reader = PDF::Reader.new(io) + reader.pages.map(&:text).join("\n\n") + rescue => e + Rails.logger.error("VectorStore::Embeddable PDF extraction error: #{e.message}") + nil + end + + # Split text on paragraph boundaries (~2000 char chunks, ~200 char overlap). + # Paragraphs longer than CHUNK_SIZE are hard-split to avoid overflowing + # embedding model token limits. + def chunk_text(text) + return [] if text.blank? + + paragraphs = text.split(/\n\s*\n/) + chunks = [] + current_chunk = +"" + + paragraphs.each do |para| + para = para.strip + next if para.empty? + + # Hard-split oversized paragraphs into CHUNK_SIZE slices with overlap + slices = if para.length > CHUNK_SIZE + hard_split(para) + else + [ para ] + end + + slices.each do |slice| + if current_chunk.empty? + current_chunk << slice + elsif (current_chunk.length + slice.length + 2) <= CHUNK_SIZE + current_chunk << "\n\n" << slice + else + chunks << current_chunk.freeze + overlap = current_chunk.last(CHUNK_OVERLAP) + current_chunk = +"" + current_chunk << overlap << "\n\n" << slice + end + end + end + + chunks << current_chunk.freeze unless current_chunk.empty? + chunks + end + + # Hard-split a single long string into CHUNK_SIZE slices with CHUNK_OVERLAP. + def hard_split(text) + slices = [] + offset = 0 + while offset < text.length + slices << text[offset, CHUNK_SIZE] + offset += CHUNK_SIZE - CHUNK_OVERLAP + end + slices + end + + # Embed a single text string → vector array. + def embed(text) + response = embedding_client.post("embeddings") do |req| + req.body = { + model: embedding_model, + input: text + } + end + + data = response.body + raise VectorStore::Error, "Embedding request failed: #{data}" unless data.is_a?(Hash) && data["data"] + + data["data"].first["embedding"] + end + + # Batch embed, processing in groups of EMBED_BATCH_SIZE. + def embed_batch(texts) + vectors = [] + + texts.each_slice(EMBED_BATCH_SIZE) do |batch| + response = embedding_client.post("embeddings") do |req| + req.body = { + model: embedding_model, + input: batch + } + end + + data = response.body + raise VectorStore::Error, "Batch embedding request failed: #{data}" unless data.is_a?(Hash) && data["data"] + + # Sort by index to preserve order + sorted = data["data"].sort_by { |d| d["index"] } + vectors.concat(sorted.map { |d| d["embedding"] }) + end + + vectors + end + + def embedding_client + @embedding_client ||= Faraday.new(url: embedding_uri_base) do |f| + f.request :json + f.response :json + f.headers["Authorization"] = "Bearer #{embedding_access_token}" if embedding_access_token.present? + f.options.timeout = 120 + f.options.open_timeout = 10 + end + end + + def embedding_model + ENV.fetch("EMBEDDING_MODEL", "nomic-embed-text") + end + + def embedding_dimensions + ENV.fetch("EMBEDDING_DIMENSIONS", "1024").to_i + end + + def embedding_uri_base + ENV["EMBEDDING_URI_BASE"].presence || ENV["OPENAI_URI_BASE"].presence || "https://api.openai.com/v1/" + end + + def embedding_access_token + ENV["EMBEDDING_ACCESS_TOKEN"].presence || ENV["OPENAI_ACCESS_TOKEN"].presence + end +end diff --git a/app/models/vector_store/pgvector.rb b/app/models/vector_store/pgvector.rb index 5d4fe4a61..a434ec1f5 100644 --- a/app/models/vector_store/pgvector.rb +++ b/app/models/vector_store/pgvector.rb @@ -2,88 +2,137 @@ # # This keeps all data on your own infrastructure — no external vector-store # service required. You still need an embedding provider (e.g. OpenAI, or a -# local model served via an OpenAI-compatible endpoint) to turn text into -# vectors before insertion and at query time. +# local model served via an OpenAI-compatible endpoint such as Ollama) to turn +# text into vectors before insertion and at query time. # -# Requirements (not yet wired up): -# - PostgreSQL with the `vector` extension enabled -# - gem "neighbor" (for ActiveRecord integration) or raw SQL -# - An embedding model endpoint (EMBEDDING_MODEL_URL / EMBEDDING_MODEL_NAME) -# - A chunking strategy (see #chunk_file below) -# -# Schema sketch (for reference — migration not included): -# -# create_table :vector_store_chunks do |t| -# t.string :store_id, null: false # logical namespace -# t.string :file_id, null: false -# t.string :filename -# t.text :content # the original text chunk -# t.vector :embedding, limit: 1536 # adjust dimensions to your model -# t.jsonb :metadata, default: {} -# t.timestamps -# end -# add_index :vector_store_chunks, :store_id -# add_index :vector_store_chunks, :file_id +# Requirements: +# - PostgreSQL with the `vector` extension enabled (use pgvector/pgvector Docker image) +# - An embedding model endpoint (EMBEDDING_URI_BASE / EMBEDDING_MODEL) +# - Migration: CreateVectorStoreChunks (run with VECTOR_STORE_PROVIDER=pgvector) # class VectorStore::Pgvector < VectorStore::Base + include VectorStore::Embeddable + + PGVECTOR_SUPPORTED_EXTENSIONS = (VectorStore::Embeddable::TEXT_EXTENSIONS + [ ".pdf" ]).uniq.freeze + + def supported_extensions + PGVECTOR_SUPPORTED_EXTENSIONS + end + def create_store(name:) with_response do - # A "store" is just a logical namespace (a UUID). - # No external resource to create. - # { id: SecureRandom.uuid } - raise VectorStore::Error, "Pgvector adapter is not yet implemented" + { id: SecureRandom.uuid } end end def delete_store(store_id:) with_response do - # TODO: DELETE FROM vector_store_chunks WHERE store_id = ? - raise VectorStore::Error, "Pgvector adapter is not yet implemented" + connection.exec_delete( + "DELETE FROM vector_store_chunks WHERE store_id = $1", + "VectorStore::Pgvector DeleteStore", + [ bind_param("store_id", store_id) ] + ) end end def upload_file(store_id:, file_content:, filename:) with_response do - # 1. chunk_file(file_content, filename) → array of text chunks - # 2. embed each chunk via the configured embedding model - # 3. INSERT INTO vector_store_chunks (store_id, file_id, filename, content, embedding) - raise VectorStore::Error, "Pgvector adapter is not yet implemented" + text = extract_text(file_content, filename) + raise VectorStore::Error, "Could not extract text from #{filename}" if text.blank? + + chunks = chunk_text(text) + raise VectorStore::Error, "No chunks produced from #{filename}" if chunks.empty? + + vectors = embed_batch(chunks) + file_id = SecureRandom.uuid + now = Time.current + + connection.transaction do + chunks.each_with_index do |chunk_content, index| + embedding_literal = "[#{vectors[index].join(',')}]" + + connection.exec_insert( + <<~SQL, + INSERT INTO vector_store_chunks + (id, store_id, file_id, filename, chunk_index, content, embedding, metadata, created_at, updated_at) + VALUES + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + SQL + "VectorStore::Pgvector InsertChunk", + [ + bind_param("id", SecureRandom.uuid), + bind_param("store_id", store_id), + bind_param("file_id", file_id), + bind_param("filename", filename), + bind_param("chunk_index", index), + bind_param("content", chunk_content), + bind_param("embedding", embedding_literal, ActiveRecord::Type::String.new), + bind_param("metadata", "{}"), + bind_param("created_at", now), + bind_param("updated_at", now) + ] + ) + end + end + + { file_id: file_id } end end def remove_file(store_id:, file_id:) with_response do - # TODO: DELETE FROM vector_store_chunks WHERE store_id = ? AND file_id = ? - raise VectorStore::Error, "Pgvector adapter is not yet implemented" + connection.exec_delete( + "DELETE FROM vector_store_chunks WHERE store_id = $1 AND file_id = $2", + "VectorStore::Pgvector RemoveFile", + [ + bind_param("store_id", store_id), + bind_param("file_id", file_id) + ] + ) end end def search(store_id:, query:, max_results: 10) with_response do - # 1. embed(query) → vector - # 2. SELECT content, filename, file_id, - # 1 - (embedding <=> query_vector) AS score - # FROM vector_store_chunks - # WHERE store_id = ? - # ORDER BY embedding <=> query_vector - # LIMIT max_results - raise VectorStore::Error, "Pgvector adapter is not yet implemented" + query_vector = embed(query) + vector_literal = "[#{query_vector.join(',')}]" + + results = connection.exec_query( + <<~SQL, + SELECT content, filename, file_id, + 1 - (embedding <=> $1::vector) AS score + FROM vector_store_chunks + WHERE store_id = $2 + ORDER BY embedding <=> $1::vector + LIMIT $3 + SQL + "VectorStore::Pgvector Search", + [ + bind_param("embedding", vector_literal, ActiveRecord::Type::String.new), + bind_param("store_id", store_id), + bind_param("limit", max_results) + ] + ) + + results.map do |row| + { + content: row["content"], + filename: row["filename"], + score: row["score"].to_f, + file_id: row["file_id"] + } + end end end private - # Placeholder: split file content into overlapping text windows. - # A real implementation would handle PDFs, DOCX, etc. via - # libraries like `pdf-reader`, `docx`, or an extraction service. - def chunk_file(file_content, filename) - # TODO: implement format-aware chunking - [] + def connection + ActiveRecord::Base.connection end - # Placeholder: call an embedding API to turn text into a vector. - def embed(text) - # TODO: call EMBEDDING_MODEL_URL or OpenAI embeddings endpoint - raise VectorStore::Error, "Embedding model not configured" + def bind_param(name, value, type = nil) + type ||= ActiveModel::Type::Value.new + ActiveRecord::Relation::QueryAttribute.new(name, value, type) end end diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb new file mode 100644 index 000000000..2d1e5a036 --- /dev/null +++ b/app/policies/account_policy.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class AccountPolicy < ApplicationPolicy + def show? + record.shared_with?(user) + end + + def create? + user.member? || user.admin? + end + + def update? + permission = record.permission_for(user) + permission.in?([ :owner, :full_control ]) + end + + # For read_write users: categorize, tag, add notes/receipts on transactions + def annotate? + permission = record.permission_for(user) + permission.in?([ :owner, :full_control, :read_write ]) + end + + # Only the owner can delete the account itself. + # full_control users can delete transactions but not the account. + def destroy? + record.owned_by?(user) + end + + def manage_sharing? + record.owned_by?(user) + end + + class Scope < ApplicationPolicy::Scope + def resolve + scope.accessible_by(user) + end + end +end diff --git a/app/views/account_sharings/show.html.erb b/app/views/account_sharings/show.html.erb new file mode 100644 index 000000000..b53a6bdff --- /dev/null +++ b/app/views/account_sharings/show.html.erb @@ -0,0 +1,93 @@ +<%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) %> + <% dialog.with_body do %> + <% if @account.owned_by?(Current.user) %> + <% if @family_members.any? %> + <%= styled_form_with url: account_sharing_path(@account), method: :patch, class: "space-y-4" do |form| %> +
+
+
+

<%= t(".member") %>

+

<%= t(".permission") %>

+

<%= t(".shared") %>

+
+
+ <% @family_members.each_with_index do |member, index| %> + <% share = @account_shares[member.id] %> +
+ +
+
+
+ <%= member.initials %> +
+ <%= member.display_name %> +
+
+ <% selected_permission = share&.permission || "read_only" %> + +
+
+ <%= render DS::Toggle.new( + id: "sharing_members_#{index}_shared", + name: "sharing[members][#{index}][shared]", + checked: share.present?, + "aria-labelledby": "member-name-#{index}" + ) %> +
+
+
+ <% end %> +
+
+ <%= render DS::Button.new(text: t(".save"), class: "md:w-auto w-full justify-center") %> +
+ <% end %> + <% else %> +

<%= t(".no_members", moniker: family_moniker_downcase) %>

+ <% end %> + <% else %> + <%# Non-owner can only toggle finance inclusion %> + <% share = @account.account_shares.find_by(user: Current.user) %> + <% if share %> +
+
+
+ <%= @account.owner.initials %> +
+
+

<%= t(".owner_label", name: @account.owner.display_name) %>

+

<%= t(".permissions.#{share.permission}") %>

+
+
+ + <%= styled_form_with url: account_sharing_path(@account), method: :patch, class: "space-y-4" do |form| %> + +
+
+
+

<%= t(".include_in_finances") %>

+

<%= t(".finance_toggle_description") %>

+
+ <%= render DS::Toggle.new( + id: "include_in_finances", + name: "include_in_finances", + checked: share.include_in_finances? + ) %> +
+
+
+ <%= render DS::Button.new(text: t(".save"), class: "md:w-auto w-full justify-center") %> +
+ <% end %> +
+ <% end %> + <% end %> + <% end %> +<% end %> diff --git a/app/views/accounts/_account.html.erb b/app/views/accounts/_account.html.erb index c9eadeb86..00341560b 100644 --- a/app/views/accounts/_account.html.erb +++ b/app/views/accounts/_account.html.erb @@ -1,7 +1,9 @@ <%# locals: (account:, return_to: nil) %> +<% is_default = Current.user&.default_account_id == account.id %> + <%= turbo_frame_tag dom_id(account) do %> -
+
<%= render "accounts/logo", account: account, size: "md" %> @@ -16,47 +18,36 @@

<% else %> -
+
<%= link_to account.name, account, class: [(account.active? ? "text-primary" : "text-subdued"), "text-sm font-medium hover:underline"], data: { turbo_frame: "_top" } %> - <% if account.institution_name %> - • <%= account.institution_name %> + + <% if account.shared? %> + <%= icon("users", class: "w-3.5 h-3.5 text-secondary", title: account.owned_by?(Current.user) ? nil : account.owner&.display_name) %> + <% end %> + + <% if account.institution_name.present? %> + <% end %>
<% if account.long_subtype_label %>

<%= account.long_subtype_label %>

<% end %> + <% if account.supports_default? && is_default %> +

<%= t("accounts.account.default_label") %>

+ <% end %> + <% if account.institution_name.present? %> +

<%= account.institution_name %>

+ <% end %> <% end %>
- - <% unless account.pending_deletion? %> - <%= link_to edit_account_path(account, return_to: return_to), data: { turbo_frame: :modal }, class: "group-hover/account:flex hidden hover:opacity-80 items-center justify-center" do %> - <%= icon("pencil-line", size: "sm") %> - <% end %> - - <% if !account.linked? && ["Depository", "CreditCard", "Investment", "Crypto"].include?(account.accountable_type) %> - <%= link_to select_provider_account_path(account), - data: { turbo_frame: :modal }, - class: "group-hover/account:flex hidden hover:opacity-80 items-center justify-center gap-1", - title: t("accounts.account.link_provider") do %> - <%= icon("link", size: "sm") %> - <% end %> - <% elsif account.linked? %> - <%= link_to confirm_unlink_account_path(account), - data: { turbo_frame: :modal }, - class: "group-hover/account:flex hidden hover:opacity-80 items-center justify-center gap-1", - title: t("accounts.account.unlink_provider") do %> - <%= icon("unlink", size: "sm") %> - <% end %> - <% end %> - <% end %>
-
+
<% if account.draft? %> <% elsif account.syncing? %>
<% else %> -

"> +

"> <%= format_money account.balance_money %>

<% end %> @@ -68,14 +59,42 @@ variant: :outline, frame: :modal ) %> - <% elsif account.active? || account.disabled? %> - <%= form_with model: account, url: toggle_active_account_path(account), method: :patch, data: { turbo_frame: "_top", controller: "auto-submit-form" } do |f| %> - <%= render DS::Toggle.new( - id: "account_#{account.id}_active", - name: "active", - checked: account.active?, - data: { auto_submit_form_target: "auto" } - ) %> + <% elsif !account.pending_deletion? %> + <% permission = account.permission_for(Current.user) %> + <%= render DS::Menu.new(icon_vertical: true, mobile_fullwidth: false, max_width: "280px") do |menu| %> + <% if permission.in?([ :owner, :full_control ]) %> + <% menu.with_item(variant: "link", text: t("accounts.account.edit"), href: edit_account_path(account, return_to: return_to), icon: "pencil-line", data: { turbo_frame: :modal }) %> + <% end %> + <% menu.with_item(variant: "link", text: t("accounts.account.sharing"), href: account_sharing_path(account), icon: "users", data: { turbo_frame: :modal }) %> + + <% if Current.user&.admin? %> + <% if !account.linked? && %w[Depository CreditCard Investment Crypto].include?(account.accountable_type) %> + <% menu.with_item(variant: "link", text: t("accounts.account.link_provider"), href: select_provider_account_path(account), icon: "link", data: { turbo_frame: :modal }) %> + <% elsif account.linked? %> + <% menu.with_item(variant: "link", text: t("accounts.account.unlink_provider"), href: confirm_unlink_account_path(account), icon: "unlink", data: { turbo_frame: :modal }) %> + <% end %> + <% end %> + + <% if permission.in?([ :owner, :full_control ]) %> + <% menu.with_item(variant: "divider") %> + + <% if account.active? %> + <% menu.with_item(variant: "button", text: t("accounts.account.disable"), href: toggle_active_account_path(account), method: :patch, icon: "toggle-right", data: { turbo_frame: :_top }) %> + <% elsif account.disabled? %> + <% menu.with_item(variant: "button", text: t("accounts.account.enable"), href: toggle_active_account_path(account), method: :patch, icon: "toggle-left", data: { turbo_frame: :_top }) %> + <% end %> + <% end %> + + <% if is_default %> + <% menu.with_item(variant: "button", text: t("accounts.account.remove_default"), href: remove_default_account_path(account), method: :patch, icon: "star-off", data: { turbo_frame: :_top }) %> + <% elsif account.eligible_for_transaction_default? %> + <% menu.with_item(variant: "button", text: t("accounts.account.set_default"), href: set_default_account_path(account), method: :patch, icon: "star", data: { turbo_frame: :_top }) %> + <% end %> + + <% if account.owned_by?(Current.user) && !account.linked? %> + <% menu.with_item(variant: "divider") %> + <% menu.with_item(variant: "button", text: t("accounts.account.delete"), href: account_path(account), method: :delete, icon: "trash-2", confirm: CustomConfirm.for_resource_deletion("account", high_severity: true), data: { turbo_frame: :_top }) %> + <% end %> <% end %> <% end %>
diff --git a/app/views/accounts/_accountable_group.html.erb b/app/views/accounts/_accountable_group.html.erb index 934f2bb3e..64f655797 100644 --- a/app/views/accounts/_accountable_group.html.erb +++ b/app/views/accounts/_accountable_group.html.erb @@ -12,7 +12,7 @@
- <%= tag.p format_money(account_group.total_money), class: "text-sm font-medium text-primary" %> + <%= tag.p format_money(account_group.total_money), class: "text-sm font-medium text-primary privacy-sensitive" %> <%= turbo_frame_tag "#{account_group.key}_sparkline", src: accountable_sparkline_path(account_group.key), loading: "lazy", data: { controller: "turbo-frame-timeout", turbo_frame_timeout_timeout_value: 10000 } do %>
@@ -34,12 +34,15 @@
<%= tag.p account.name, class: class_names("text-sm text-primary font-medium truncate", "animate-pulse" => account.syncing?) %> + <% if account.shared? %> + <%= icon("users", class: "w-3 h-3 text-secondary shrink-0") %> + <% end %>
<%= tag.p account.short_subtype_label, class: "text-sm text-secondary truncate" %>
- <%= tag.p format_money(account.balance_money), class: "text-sm font-medium text-primary whitespace-nowrap" %> + <%= tag.p format_money(account.balance_money), class: "text-sm font-medium text-primary whitespace-nowrap privacy-sensitive" %> <%= turbo_frame_tag dom_id(account, :sparkline), src: sparkline_account_path(account), loading: "lazy", data: { controller: "turbo-frame-timeout", turbo_frame_timeout_timeout_value: 10000 } do %>
diff --git a/app/views/accounts/_form.html.erb b/app/views/accounts/_form.html.erb index 7f3ede919..c29962a79 100644 --- a/app/views/accounts/_form.html.erb +++ b/app/views/accounts/_form.html.erb @@ -15,6 +15,13 @@ <%= form.money_field :balance, label: t(".balance"), required: true, default_currency: Current.family.currency %> <% end %> + <% if account.new_record? && !account.linked? %> + <%= form.date_field :opening_balance_date, + label: t(".opening_balance_date_label"), + value: Time.zone.today - 2.years, + required: true %> + <% end %> + <%= yield form %>
diff --git a/app/views/accounts/_logo.html.erb b/app/views/accounts/_logo.html.erb index 8c4d9c433..cb82b28ac 100644 --- a/app/views/accounts/_logo.html.erb +++ b/app/views/accounts/_logo.html.erb @@ -7,13 +7,16 @@ "full" => "w-full h-full" } %> -<% if account.institution_domain.present? && Setting.brand_fetch_client_id.present? %> - <% logo_size = Setting.brand_fetch_logo_size %> - <%= image_tag "https://cdn.brandfetch.io/#{account.institution_domain}/icon/fallback/lettermark/w/#{logo_size}/h/#{logo_size}?c=#{Setting.brand_fetch_client_id}", class: "shrink-0 rounded-full #{size_classes[size]}" %> -<% elsif account.logo_url.present? %> - <%= image_tag account.logo_url, class: "shrink-0 rounded-full #{size_classes[size]}", loading: "lazy" %> -<% elsif account.logo.attached? %> - <%= image_tag account.logo, class: "shrink-0 rounded-full #{size_classes[size]}" %> +<% if account.logo_url.present? %> + <%= image_tag account.logo_url, + class: "shrink-0 rounded-full #{size_classes[size]}", + loading: "lazy" %> <% else %> - <%= render DS::FilledIcon.new(variant: :text, hex_color: color || account.accountable.color, text: account.name, size: size, rounded: true) %> + <%= render DS::FilledIcon.new( + variant: :text, + hex_color: color || account.accountable&.color, + text: account.name, + size: size, + rounded: true + ) %> <% end %> diff --git a/app/views/accounts/_summary_card.html.erb b/app/views/accounts/_summary_card.html.erb index fe327728e..b711c1cd9 100644 --- a/app/views/accounts/_summary_card.html.erb +++ b/app/views/accounts/_summary_card.html.erb @@ -2,7 +2,7 @@

<%= title %>

-

+

<%= content %>

diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index 86e726e4c..ec2fce04e 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -44,7 +44,7 @@ <% if @mercury_items.any? %> <%= render @mercury_items.sort_by(&:created_at) %> <% end %> - + <% if @coinbase_items.any? %> <%= render @coinbase_items.sort_by(&:created_at) %> <% end %> diff --git a/app/views/accounts/index/_account_groups.erb b/app/views/accounts/index/_account_groups.erb index b5ebcf7f4..a66ddf5e3 100644 --- a/app/views/accounts/index/_account_groups.erb +++ b/app/views/accounts/index/_account_groups.erb @@ -1,5 +1,6 @@ <%# locals: (accounts:) %> +<% ActiveRecord::Associations::Preloader.new(records: accounts, associations: :account_shares).call if accounts.any? %> <% accounts.group_by(&:accountable_type).sort_by { |group, _| group }.each do |group, accounts| %>
diff --git a/app/views/accounts/new/_method_selector.html.erb b/app/views/accounts/new/_method_selector.html.erb index 135cd0104..f98fef36c 100644 --- a/app/views/accounts/new/_method_selector.html.erb +++ b/app/views/accounts/new/_method_selector.html.erb @@ -17,7 +17,8 @@ <%= t("accounts.new.method_selector.manual_entry") %> <% end %> - <%# Dynamic provider links %> + <%# Dynamic provider links - only admins can create provider connections %> + <% if Current.user&.admin? %> <% provider_configs.each do |config| %> <% link_path = config[:new_account_path].call(accountable_type, params[:return_to]) %> <% is_lunchflow = config[:key] == "lunchflow" %> @@ -44,6 +45,7 @@ <% end %> <% end %> <% end %> + <% end %>
<% end %> diff --git a/app/views/accounts/show/_activity.html.erb b/app/views/accounts/show/_activity.html.erb index ae8aeb8b2..76de75102 100644 --- a/app/views/accounts/show/_activity.html.erb +++ b/app/views/accounts/show/_activity.html.erb @@ -5,30 +5,32 @@
<%= tag.h2 t(".title"), class: "font-medium text-lg" %> <% unless @account.linked? %> - <%= render DS::Menu.new(variant: "button") do |menu| %> - <% menu.with_button(text: "New", variant: "secondary", icon: "plus") %> + <% if @account.permission_for(Current.user).in?([ :owner, :full_control ]) %> + <%= render DS::Menu.new(variant: "button") do |menu| %> + <% menu.with_button(text: "New", variant: "secondary", icon: "plus") %> - <% menu.with_item( - variant: "link", - text: "New balance", - icon: "circle-dollar-sign", - href: new_valuation_path(account_id: @account.id), - data: { turbo_frame: :modal }) %> - - <% if @account.supports_trades? %> <% menu.with_item( variant: "link", - text: t(".new_trade"), - icon: "credit-card", - href: new_trade_path(account_id: @account.id), - data: { turbo_frame: :modal }) %> - <% elsif !@account.crypto? %> - <% menu.with_item( - variant: "link", - text: t(".new_transaction"), - icon: "credit-card", - href: new_transaction_path(account_id: @account.id), + text: "New balance", + icon: "circle-dollar-sign", + href: new_valuation_path(account_id: @account.id), data: { turbo_frame: :modal }) %> + + <% if @account.supports_trades? %> + <% menu.with_item( + variant: "link", + text: t(".new_trade"), + icon: "credit-card", + href: new_trade_path(account_id: @account.id), + data: { turbo_frame: :modal }) %> + <% elsif !@account.crypto? %> + <% menu.with_item( + variant: "link", + text: t(".new_transaction"), + icon: "credit-card", + href: new_transaction_path(account_id: @account.id), + data: { turbo_frame: :modal }) %> + <% end %> <% end %> <% end %> <% end %> diff --git a/app/views/accounts/show/_header.html.erb b/app/views/accounts/show/_header.html.erb index 41d731236..cafd97811 100644 --- a/app/views/accounts/show/_header.html.erb +++ b/app/views/accounts/show/_header.html.erb @@ -9,6 +9,9 @@

"><%= title %>

+ <% if account.shared? %> + <%= icon("users", class: "w-4 h-4 text-secondary", title: account.owned_by?(Current.user) ? nil : account.owner&.display_name) %> + <% end %> <% if account.tax_treatment.present? %> <%= render partial: "accounts/tax_treatment_badge", locals: { account: account } %> <% end %> diff --git a/app/views/accounts/show/_menu.html.erb b/app/views/accounts/show/_menu.html.erb index ee3db0c0d..45a39fe18 100644 --- a/app/views/accounts/show/_menu.html.erb +++ b/app/views/accounts/show/_menu.html.erb @@ -1,27 +1,33 @@ <%# locals: (account:) %> +<% permission = account.permission_for(Current.user) %> <%= render DS::Menu.new(testid: "account-menu") do |menu| %> - <% menu.with_item(variant: "link", text: "Edit", href: edit_account_path(account), icon: "pencil-line", data: { turbo_frame: :modal }) %> + <% if permission.in?([ :owner, :full_control ]) %> + <% menu.with_item(variant: "link", text: "Edit", href: edit_account_path(account), icon: "pencil-line", data: { turbo_frame: :modal }) %> + <% end %> + <% menu.with_item(variant: "link", text: "Sharing", href: account_sharing_path(account), icon: "users", data: { turbo_frame: :modal }) %> - <% if account.supports_trades? %> - <% menu.with_item( - variant: "link", - text: t(".import_trades"), - href: imports_path({ import: { type: "TradeImport", account_id: account.id } }), - icon: "download", - data: { turbo_frame: :_top } - ) %> - <% elsif !account.crypto? %> - <% menu.with_item( - variant: "link", - text: t(".import_transactions"), - href: imports_path({ import: { type: "TransactionImport", account_id: account.id } }), - icon: "download", - data: { turbo_frame: :_top } - ) %> + <% if permission.in?([ :owner, :full_control ]) %> + <% if account.supports_trades? %> + <% menu.with_item( + variant: "link", + text: t(".import_trades"), + href: imports_path({ import: { type: "TradeImport", account_id: account.id } }), + icon: "download", + data: { turbo_frame: :_top } + ) %> + <% elsif !account.crypto? %> + <% menu.with_item( + variant: "link", + text: t(".import_transactions"), + href: imports_path({ import: { type: "TransactionImport", account_id: account.id } }), + icon: "download", + data: { turbo_frame: :_top } + ) %> + <% end %> <% end %> - <% unless account.linked? %> + <% if account.owned_by?(Current.user) && !account.linked? %> <% menu.with_item( variant: "button", text: "Delete account", diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb index abc09a652..6d10e0570 100644 --- a/app/views/admin/users/index.html.erb +++ b/app/views/admin/users/index.html.erb @@ -43,80 +43,152 @@
- +

<%= t(".section_title") %>

-
- <% if @users.any? %> - - - - - - - - - - - - <% @users.each do |user| %> - - - - - - + <% if pending_invitations.any? %> + + <% pending_invitations.each do |invitation| %> + + + + + + + <% end %> + + <% end %> +
<%= t(".table.user") %><%= t(".table.trial_ends_at") %><%= t(".table.family_accounts") %><%= t(".table.family_transactions") %><%= t(".table.role") %>
-
-
- <%= user.initials %> -
-
-

<%= user.display_name %>

-

<%= user.email %>

-

- <%= t(".table.last_login") %>: <%= @last_login_by_user[user.id]&.to_fs(:long) || t(".table.never") %> - <%= t(".table.session_count") %>: <%= number_with_delimiter(@sessions_count_by_user[user.id] || 0) %> -

-
-
-
- <%= user.family.subscription&.trial_ends_at&.to_fs(:long) || t(".not_available") %> - - <%= number_with_delimiter(@accounts_count_by_family[user.family_id] || 0) %> - - <%= number_with_delimiter(@entries_count_by_family[user.family_id] || 0) %> - - <% if user.id == Current.user.id %> - <%= t(".you") %> - <% else %> - <%= form_with model: [:admin, user], method: :patch, class: "flex items-center justify-end gap-2" do |form| %> - <%= form.select :role, - options_for_select([ - [t(".roles.guest"), "guest"], - [t(".roles.member", default: "Member"), "member"], - [t(".roles.admin"), "admin"], - [t(".roles.super_admin"), "super_admin"] - ], user.role), - {}, - class: "text-sm rounded-lg border border-primary bg-container text-primary px-2 py-1", - onchange: "this.form.requestSubmit()" %> - <% end %> + + <% if @families_with_users.any? %> +
+ <% @families_with_users.each do |family, users| %> + <% pending_invitations = @invitations_by_family[family.id] || [] %> +
"> + +
+ <%= icon "users", class: "w-5 h-5 text-secondary shrink-0" %> +
+

<%= family.name.presence || t(".unnamed_family") %>

+

+ <%= t(".family_summary", + members: users.size, + accounts: number_with_delimiter(@accounts_count_by_family[family.id] || 0), + transactions: number_with_delimiter(@entries_count_by_family[family.id] || 0)) %> +

+
+
+
+ <% sub = family.subscription %> + <% if sub&.trialing? %> + + <%= t(".table.trial_ends_at") %>: <%= sub.trial_ends_at&.to_fs(:long) || t(".not_available") %> + + <% elsif sub %> + "> + <%= sub.status.humanize %> + + <% else %> + <%= t(".no_subscription") %> + <% end %> + <%= icon "chevron-down", class: "w-4 h-4 text-secondary transition-transform group-open:rotate-180" %> +
+
+ +
+ + + + + + + + + + + <% users.each do |user| %> + + + + + + <% end %> - - - <% end %> - -
<%= t(".table.user") %><%= t(".table.last_login") %><%= t(".table.session_count") %><%= t(".table.role") %>
+
+
+ <%= user.initials %> +
+
+

<%= user.display_name %>

+

<%= user.email %>

+
+
+
+ <%= @last_login_by_user[user.id]&.to_fs(:long) || t(".table.never") %> + + <%= number_with_delimiter(@sessions_count_by_user[user.id] || 0) %> + + <% if user.id == Current.user.id %> + <%= t(".you") %> + <% else %> + <%= form_with model: [:admin, user], method: :patch, class: "flex items-center justify-end gap-2", data: { controller: "auto-submit-form" } do |form| %> + <%= form.select :role, + options_for_select([ + [t(".roles.guest"), "guest"], + [t(".roles.member", default: "Member"), "member"], + [t(".roles.admin"), "admin"], + [t(".roles.super_admin"), "super_admin"] + ], user.role), + {}, + class: "text-sm rounded-lg border border-primary bg-container text-primary px-2 py-1", + data: { auto_submit_form_target: "auto" } %> + <% end %> + <% end %> +
- <% else %> -
- <%= icon "users", class: "w-12 h-12 mx-auto text-secondary mb-3" %> -

<%= t(".no_users") %>

-
- <% end %> -
+
+
+ <%= icon "mail", class: "w-5 h-5 text-secondary shrink-0" %> +
+

<%= invitation.email %>

+

<%= t(".invitations.pending_label") %>

+
+
+
+ <%= t(".invitations.expires", date: invitation.expires_at.to_fs(:long)) %> + + — + + <%= form_with url: admin_invitation_path(invitation), method: :delete, class: "inline" do |f| %> + + <% end %> +
+ <% if pending_invitations.any? %> + <%= form_with url: invitations_admin_family_path(family), method: :delete, + data: { admin_invitation_delete_target: "destroyAllForm" }, + class: "hidden" do |f| %> + <% end %> + <% end %> +
+
+ <% end %> +
+ <% else %> +
+ <%= icon "users", class: "w-12 h-12 mx-auto text-secondary mb-3" %> +

<%= t(".no_users") %>

+
+ <% end %>
- <%= settings_section title: t(".role_descriptions_title"), collapsible: true, open: false do %> + <%= settings_section title: t(".role_descriptions_title"), collapsible: true, open: true do %>
diff --git a/app/views/api/v1/categories/_category.json.jbuilder b/app/views/api/v1/categories/_category.json.jbuilder index f0ebfe0cf..926df6584 100644 --- a/app/views/api/v1/categories/_category.json.jbuilder +++ b/app/views/api/v1/categories/_category.json.jbuilder @@ -2,7 +2,6 @@ json.id category.id json.name category.name -json.classification category.classification json.color category.color json.icon category.lucide_icon diff --git a/app/views/api/v1/transactions/_transaction.json.jbuilder b/app/views/api/v1/transactions/_transaction.json.jbuilder index 617f47505..9f3a47a98 100644 --- a/app/views/api/v1/transactions/_transaction.json.jbuilder +++ b/app/views/api/v1/transactions/_transaction.json.jbuilder @@ -31,7 +31,6 @@ if transaction.category.present? json.category do json.id transaction.category.id json.name transaction.category.name - json.classification transaction.category.classification json.color transaction.category.color json.icon transaction.category.lucide_icon end diff --git a/app/views/binance_items/_binance_item.html.erb b/app/views/binance_items/_binance_item.html.erb new file mode 100644 index 000000000..d08e6d34d --- /dev/null +++ b/app/views/binance_items/_binance_item.html.erb @@ -0,0 +1,132 @@ +<%# locals: (binance_item:, unlinked_count: binance_item.unlinked_accounts_count) %> + +<%= tag.div id: dom_id(binance_item) do %> +
+ + <%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> + +
+
+ <%= icon "coins", size: "sm", class: "text-[#F0B90B]" %> +
+
+ +
+
+ <%= tag.p binance_item.institution_display_name, class: "font-medium text-primary" %> + <% if binance_item.scheduled_for_deletion? %> +

<%= t(".deletion_in_progress") %>

+ <% end %> +
+

<%= t(".provider_name") %>

+ <% if binance_item.syncing? %> +
+ <%= icon "loader", size: "sm", class: "animate-spin" %> + <%= tag.span t(".syncing") %> +
+ <% elsif binance_item.requires_update? %> +
+ <%= icon "alert-triangle", size: "sm", color: "warning" %> + <%= tag.span t(".reconnect") %> +
+ <% else %> +

+ <% if binance_item.last_synced_at %> + <% if binance_item.sync_status_summary %> + <%= t(".status_with_summary", timestamp: time_ago_in_words(binance_item.last_synced_at), summary: binance_item.sync_status_summary) %> + <% else %> + <%= t(".status", timestamp: time_ago_in_words(binance_item.last_synced_at)) %> + <% end %> + <% else %> + <%= t(".status_never") %> + <% end %> +

+ <% end %> +
+
+ + <% if Current.user&.admin? %> +
+ <% if binance_item.requires_update? %> + <%= render DS::Link.new( + text: t(".update_credentials"), + icon: "refresh-cw", + variant: "secondary", + href: settings_providers_path, + frame: "_top" + ) %> + <% else %> + <%= icon( + "refresh-cw", + as_button: true, + href: sync_binance_item_path(binance_item), + disabled: binance_item.syncing? + ) %> + <% end %> + + <%= render DS::Menu.new do |menu| %> + <% if unlinked_count.to_i > 0 %> + <% menu.with_item( + variant: "link", + text: t(".import_accounts_menu"), + icon: "plus", + href: setup_accounts_binance_item_path(binance_item), + frame: :modal + ) %> + <% end %> + <% menu.with_item( + variant: "button", + text: t(".delete"), + icon: "trash-2", + href: binance_item_path(binance_item), + method: :delete, + confirm: CustomConfirm.for_resource_deletion(binance_item.institution_display_name, high_severity: true) + ) %> + <% end %> +
+ <% end %> + + <% unless binance_item.scheduled_for_deletion? %> +
+ <% if binance_item.accounts.any? %> + <%= render "accounts/index/account_groups", accounts: binance_item.accounts %> + <% binance_item.stale_rate_accounts.each do |ba| %> +
+ ~ + <%= icon "triangle-alert", size: "sm" %> + + <%= t("binance_items.binance_item.stale_rate_warning", + date: ba.extra.dig("binance", "rate_target_date")) %> + +
+ <% end %> + <% end %> + + <% stats = binance_item.syncs.ordered.first&.sync_stats || {} %> + <%= render ProviderSyncSummary.new( + stats: stats, + provider_item: binance_item + ) %> + + <% if unlinked_count.to_i > 0 && binance_item.accounts.empty? %> +
+

<%= t(".setup_needed") %>

+

<%= t(".setup_description") %>

+ <%= render DS::Link.new( + text: t(".setup_action"), + icon: "plus", + variant: "primary", + href: setup_accounts_binance_item_path(binance_item), + frame: :modal + ) %> +
+ <% elsif binance_item.accounts.empty? && binance_item.binance_accounts.none? %> +
+

<%= t(".no_accounts_title") %>

+

<%= t(".no_accounts_message") %>

+
+ <% end %> +
+ <% end %> +
+<% end %> diff --git a/app/views/binance_items/select_existing_account.html.erb b/app/views/binance_items/select_existing_account.html.erb new file mode 100644 index 000000000..8f12e628a --- /dev/null +++ b/app/views/binance_items/select_existing_account.html.erb @@ -0,0 +1,43 @@ +<%# Modal: Link an existing manual account to a Binance account %> +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) %> + + <% dialog.with_body do %> + <% if @available_binance_accounts.blank? %> +
+

<%= t(".no_accounts_found") %>

+
    +
  • <%= t(".wait_for_sync") %>
  • +
  • <%= t(".check_provider_health") %>
  • +
+
+ <% else %> + <%= form_with url: link_existing_account_binance_items_path, method: :post, class: "space-y-4" do %> + <%= hidden_field_tag :account_id, @account.id %> +
+ <% @available_binance_accounts.each do |ba| %> + + <% end %> +
+ +
+ <%= render DS::Button.new(text: t(".link"), variant: :primary, icon: "link-2", type: :submit) %> + <%= render DS::Link.new(text: t(".cancel"), variant: :secondary, href: accounts_path, data: { turbo_frame: "_top" }) %> +
+ <% end %> + <% end %> + <% end %> + <% end %> +<% end %> diff --git a/app/views/binance_items/setup_accounts.html.erb b/app/views/binance_items/setup_accounts.html.erb new file mode 100644 index 000000000..4b7ab0af5 --- /dev/null +++ b/app/views/binance_items/setup_accounts.html.erb @@ -0,0 +1,104 @@ +<% content_for :title, t(".title") %> + +<%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) do %> +
+ <%= icon "coins", class: "text-primary" %> + <%= t(".subtitle") %> +
+ <% end %> + + <% dialog.with_body do %> + <%= form_with url: complete_account_setup_binance_item_path(@binance_item), + method: :post, + local: true, + id: "binance-setup-form", + data: { + controller: "loading-button", + action: "submit->loading-button#showLoading", + loading_button_loading_text_value: t(".creating"), + turbo_frame: "_top" + }, + class: "space-y-6" do |form| %> + +
+
+
+ <%= icon "info", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %> +
+

+ <%= t(".instructions") %> +

+
+
+
+ + <% if @binance_accounts.empty? %> +
+

<%= t(".no_accounts") %>

+
+ <% else %> +
+
+ + <%= t(".accounts_count", count: @binance_accounts.count) %> + + +
+ +
+ <% @binance_accounts.each do |binance_account| %> + + <% end %> +
+
+ <% end %> +
+ +
+ <%= render DS::Button.new( + text: t(".import_selected"), + variant: "primary", + icon: "plus", + type: "submit", + class: "flex-1", + data: { loading_button_target: "button" } + ) %> + <%= render DS::Link.new( + text: t(".cancel"), + variant: "secondary", + href: accounts_path + ) %> +
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/budget_categories/_allocation_progress.erb b/app/views/budget_categories/_allocation_progress.erb index 8302a57d0..af854fe00 100644 --- a/app/views/budget_categories/_allocation_progress.erb +++ b/app/views/budget_categories/_allocation_progress.erb @@ -17,9 +17,9 @@ <% end %>

- "><%= format_money(budget.allocated_spending_money) %> + "><%= format_money(budget.allocated_spending_money) %> / - <%= format_money(budget.budgeted_spending_money) %> + <%= format_money(budget.budgeted_spending_money) %>

@@ -34,10 +34,10 @@
<% if budget.available_to_allocate.negative? %>

- Budget exceeded by <%= format_money(budget.available_to_allocate_money.abs) %> + Budget exceeded by <%= format_money(budget.available_to_allocate_money.abs) %>

<% else %> - <%= format_money(budget.available_to_allocate_money) %> + <%= format_money(budget.available_to_allocate_money) %> left to allocate <% end %>
diff --git a/app/views/budget_categories/_budget_category.html.erb b/app/views/budget_categories/_budget_category.html.erb index efa6467c1..8597a1b93 100644 --- a/app/views/budget_categories/_budget_category.html.erb +++ b/app/views/budget_categories/_budget_category.html.erb @@ -57,13 +57,13 @@
<%= t("reports.budget_performance.spent") %>: - + <%= format_money(budget_category.actual_spending_money) %>
<%= t("reports.budget_performance.budgeted") %>: - + <%= format_money(budget_category.budgeted_spending_money) %> <% if budget_category.inherits_parent_budget? %> @@ -73,12 +73,12 @@
<% if budget_category.available_to_spend >= 0 %> <%= t("reports.budget_performance.remaining") %>: - "> + privacy-sensitive"> <%= format_money(budget_category.available_to_spend_money) %> <% else %> <%= t("reports.budget_performance.over_by") %>: - + <%= format_money(budget_category.available_to_spend_money.abs) %> <% end %> @@ -116,13 +116,13 @@

<%= budget_category.category.name %>

-

+

<%= budget_category.median_monthly_expense_money.format %> avg

-

<%= format_money(budget_category.actual_spending_money) %>

+

<%= format_money(budget_category.actual_spending_money) %>

<% end %> diff --git a/app/views/budget_categories/_budget_category_form.html.erb b/app/views/budget_categories/_budget_category_form.html.erb index f4fce9d86..59a564a8b 100644 --- a/app/views/budget_categories/_budget_category_form.html.erb +++ b/app/views/budget_categories/_budget_category_form.html.erb @@ -22,7 +22,6 @@ step: currency.step, id: dom_id(budget_category, :budgeted_spending), min: 0, - max: budget_category.max_allocation, data: { auto_submit_form_target: "auto" }, title: budget_category.subcategory? ? "Leave empty to share parent's budget" : nil %>
diff --git a/app/views/budget_categories/show.html.erb b/app/views/budget_categories/show.html.erb index affb01afc..154a7958f 100644 --- a/app/views/budget_categories/show.html.erb +++ b/app/views/budget_categories/show.html.erb @@ -8,11 +8,11 @@ <% if @budget_category.budget.initialized? %>

- + <%= format_money(@budget_category.actual_spending_money) %> / - <%= format_money(@budget_category.budgeted_spending_money) %> + <%= format_money(@budget_category.budgeted_spending_money) %>

<% end %>
@@ -33,7 +33,7 @@
<%= @budget_category.budget.start_date.strftime("%b %Y") %> spending
-
+
<%= format_money @budget_category.actual_spending_money %>
@@ -42,19 +42,19 @@
Status
<% if @budget_category.available_to_spend.negative? %> -
+
<%= icon "alert-circle", size: "sm", color: "destructive" %> <%= format_money @budget_category.available_to_spend_money.abs %> overspent
<% elsif @budget_category.available_to_spend.zero? %> -
+
<%= icon "x-circle", size: "sm", color: "warning" %> <%= format_money @budget_category.available_to_spend_money %> left
<% else %> -
+
<%= icon "check-circle", size: "sm", color: "success" %> <%= format_money @budget_category.available_to_spend_money %> left @@ -64,7 +64,7 @@
Budgeted
-
+
<%= format_money @budget_category.budgeted_spending_money %>
@@ -72,14 +72,14 @@
Monthly average spending
-
+
<%= @budget_category.avg_monthly_expense_money.format %>
Monthly median spending
-
+
<%= @budget_category.median_monthly_expense_money.format %>
@@ -111,7 +111,7 @@ class: "text-primary hover:underline", data: { turbo_frame: :_top } %>
-

+

<%= format_money transaction.entry.amount_money %>

diff --git a/app/views/budget_categories/update.turbo_stream.erb b/app/views/budget_categories/update.turbo_stream.erb index bf44fea3c..0991695cd 100644 --- a/app/views/budget_categories/update.turbo_stream.erb +++ b/app/views/budget_categories/update.turbo_stream.erb @@ -5,14 +5,18 @@ <%= turbo_stream.replace dom_id(@budget, :confirm_button), partial: "budget_categories/confirm_button", locals: { budget: @budget } %> <% if @budget_category.subcategory? %> + <% if (parent_budget_category = @budget_category.parent_budget_category) %> + <%= turbo_stream.replace dom_id(parent_budget_category, :form), partial: "budget_categories/budget_category_form", locals: { budget_category: parent_budget_category } %> + <% end %> + <%# Update sibling subcategories when a subcategory changes %> <% @budget_category.siblings.each do |sibling| %> - <%= turbo_stream.update dom_id(sibling, :form), partial: "budget_categories/budget_category_form", locals: { budget_category: sibling } %> + <%= turbo_stream.replace dom_id(sibling, :form), partial: "budget_categories/budget_category_form", locals: { budget_category: sibling } %> <% end %> <% else %> <%# Update all subcategories when a parent category changes %> <% @budget_category.subcategories.each do |subcategory| %> - <%= turbo_stream.update dom_id(subcategory, :form), partial: "budget_categories/budget_category_form", locals: { budget_category: subcategory } %> + <%= turbo_stream.replace dom_id(subcategory, :form), partial: "budget_categories/budget_category_form", locals: { budget_category: subcategory } %> <% end %> <% end %> diff --git a/app/views/budgets/_actuals_summary.html.erb b/app/views/budgets/_actuals_summary.html.erb index 5b4adf1d6..fe9509d9a 100644 --- a/app/views/budgets/_actuals_summary.html.erb +++ b/app/views/budgets/_actuals_summary.html.erb @@ -4,7 +4,7 @@

Income

- + <%= budget.actual_income_money.format %> @@ -32,7 +32,7 @@

Expenses

- <%= budget.actual_spending_money.format %> + <%= budget.actual_spending_money.format %> <% if budget.expense_category_totals.any? %>
diff --git a/app/views/budgets/_budget_donut.html.erb b/app/views/budgets/_budget_donut.html.erb index 8726f4e43..8879d9bfc 100644 --- a/app/views/budgets/_budget_donut.html.erb +++ b/app/views/budgets/_budget_donut.html.erb @@ -8,7 +8,7 @@ Spent
-
"> +
"> <%= format_money(budget.actual_spending_money) %>
@@ -21,7 +21,7 @@ href: edit_budget_path(budget) ) %> <% else %> -
+
<%= format_money Money.new(0, budget.currency || budget.family.currency) %>
@@ -42,7 +42,7 @@

<%= bc.category.name %>

-

"> +

"> <%= format_money(bc.actual_spending_money) %>

@@ -61,7 +61,7 @@ diff --git a/app/views/budgets/_budgeted_summary.html.erb b/app/views/budgets/_budgeted_summary.html.erb index 37e225e75..07905d0d3 100644 --- a/app/views/budgets/_budgeted_summary.html.erb +++ b/app/views/budgets/_budgeted_summary.html.erb @@ -4,7 +4,7 @@

Expected income

- + <%= format_money(budget.expected_income_money) %> @@ -18,7 +18,7 @@
<% end %>
-
+

<%= format_money(budget.actual_income_money) %> earned

<% if budget.remaining_expected_income.negative? %> @@ -34,7 +34,7 @@

Budgeted

- + <%= format_money(budget.budgeted_spending_money) %> @@ -48,7 +48,7 @@
<% end %>
-
+

<%= format_money(budget.actual_spending_money) %> spent

<% if budget.available_to_spend.negative? %> diff --git a/app/views/budgets/_copy_previous_prompt.html.erb b/app/views/budgets/_copy_previous_prompt.html.erb new file mode 100644 index 000000000..8edd87c1d --- /dev/null +++ b/app/views/budgets/_copy_previous_prompt.html.erb @@ -0,0 +1,26 @@ +<%# locals: (budget:, source_budget:) %> + +

+ <%= icon "copy", size: "lg" %> + +
+

<%= t("budgets.copy_previous_prompt.title") %>

+

<%= t("budgets.copy_previous_prompt.description", source_name: source_budget.name) %>

+
+ +
+ <%= render DS::Button.new( + text: t("budgets.copy_previous_prompt.copy_button", source_name: source_budget.name), + href: copy_previous_budget_path(budget), + method: :post, + icon: "copy" + ) %> + + <%= render DS::Link.new( + text: t("budgets.copy_previous_prompt.fresh_button"), + variant: "secondary", + icon: "plus", + href: edit_budget_path(budget) + ) %> +
+
diff --git a/app/views/budgets/show.html.erb b/app/views/budgets/show.html.erb index 33eea2d50..768cf7727 100644 --- a/app/views/budgets/show.html.erb +++ b/app/views/budgets/show.html.erb @@ -9,8 +9,10 @@ <%# Top Section: Donut and Summary side by side %>
<%# Budget Donut %> -
- <% if @budget.available_to_allocate.negative? %> +
+ <% if !@budget.initialized? && @source_budget.present? %> + <%= render "budgets/copy_previous_prompt", budget: @budget, source_budget: @source_budget %> + <% elsif @budget.initialized? && @budget.available_to_allocate.negative? %> <%= render "budgets/over_allocation_warning", budget: @budget %> <% else %> <%= render "budgets/budget_donut", budget: @budget %> diff --git a/app/views/categories/_form.html.erb b/app/views/categories/_form.html.erb index d699d3c4d..1f3e5fbeb 100644 --- a/app/views/categories/_form.html.erb +++ b/app/views/categories/_form.html.erb @@ -30,7 +30,7 @@ + <% if @enhanceable_count > 0 && @llm_available %> +
+
+ <%= icon "sparkles", class: "w-5 h-5 text-link mt-0.5 flex-shrink-0" %> +

<%= t(".enhance_info", count: @enhanceable_count) %>

+
+ <%= render DS::Button.new( + text: t(".enhance_button"), + variant: :outline, + size: :sm, + icon: "sparkles", + href: enhance_family_merchants_path + ) %> +
+ <% end %> + <% if @provider_merchants.any? %>
diff --git a/app/views/holdings/_cost_basis_cell.html.erb b/app/views/holdings/_cost_basis_cell.html.erb index 6e4b630bb..f482d0097 100644 --- a/app/views/holdings/_cost_basis_cell.html.erb +++ b/app/views/holdings/_cost_basis_cell.html.erb @@ -61,12 +61,12 @@
<%= currency.symbol %> - " data-action="input->cost-basis-form#updatePerShare" data-cost-basis-form-target="total"> <%= currency.iso_code %> @@ -82,11 +82,11 @@
<%= currency.symbol %> - " data-action="input->cost-basis-form#updateTotal" data-cost-basis-form-target="perShare"> <%= currency.iso_code %> diff --git a/app/views/holdings/_holding.html.erb b/app/views/holdings/_holding.html.erb index 164acfc1e..c0ba88e64 100644 --- a/app/views/holdings/_holding.html.erb +++ b/app/views/holdings/_holding.html.erb @@ -37,7 +37,7 @@
<% if holding.amount_money %> - <%= tag.p format_money holding.amount_money %> + <%= tag.p format_money(holding.amount_money), class: "privacy-sensitive" %> <% else %> <%= tag.p "--", class: "text-secondary" %> <% end %> @@ -47,8 +47,8 @@
<%# Show Total Return (unrealized G/L) when cost basis exists (from trades or manual) %> <% if holding.trend %> - <%= tag.p format_money(holding.trend.value), style: "color: #{holding.trend.color};" %> - <%= tag.p "(#{holding.trend.percent_formatted})", style: "color: #{holding.trend.color};" %> + <%= tag.p format_money(holding.trend.value), class: "privacy-sensitive", style: "color: #{holding.trend.color};" %> + <%= tag.p "(#{holding.trend.percent_formatted})", class: "privacy-sensitive", style: "color: #{holding.trend.color};" %> <% else %> <%= tag.p "--", class: "text-secondary" %> <%= tag.p t(".no_cost_basis"), class: "text-xs text-secondary" %> diff --git a/app/views/holdings/show.html.erb b/app/views/holdings/show.html.erb index 927d0b9a2..38ff086c0 100644 --- a/app/views/holdings/show.html.erb +++ b/app/views/holdings/show.html.erb @@ -35,7 +35,7 @@ @@ -66,7 +66,7 @@
<%= t(".current_market_price_label") %>
-
+
<% begin %> <%= @holding.security.current_price ? format_money(@holding.security.current_price) : t(".unknown") %> <% rescue ActiveRecord::RecordInvalid %> @@ -78,6 +78,10 @@ <% end %>
+
+
<%= t(".shares_label") %>
+
<%= format_quantity(@holding.qty) %>
+
<%= t(".portfolio_weight_label") %>
<%= @holding.weight ? number_to_percentage(@holding.weight, precision: 2) : t(".unknown") %>
@@ -129,12 +133,12 @@
<%= currency.symbol %> - " data-action="input->drawer-cost-basis#updatePerShare" data-drawer-cost-basis-target="total"> <%= currency.iso_code %> @@ -149,11 +153,11 @@
<%= currency.symbol %> - " data-action="input->drawer-cost-basis#updateTotal" data-drawer-cost-basis-target="perShare"> <%= currency.iso_code %> @@ -171,6 +175,17 @@
+
<%= t(".book_value_label") %>
+
+ <% book_value = @holding.avg_cost ? @holding.avg_cost * @holding.qty : nil %> + <%= book_value ? format_money(book_value) : t(".unknown") %> +
+
+
+
<%= t(".market_value_label") %>
+
<%= format_money(@holding.amount_money) %>
+
+
<%= t(".total_return_label") %>
<% if @holding.trend %>
@@ -214,7 +229,7 @@
<% end %> - <% if @holding.cost_basis_locked? || @holding.security_remapped? || @holding.account.can_delete_holdings? %> + <% if @holding.cost_basis_locked? || @holding.security_remapped? || @holding.account.can_delete_holdings? || !@holding.security.offline? %> <% dialog.with_section(title: t(".settings"), open: true) do %>
<% if @holding.security_remapped? %> @@ -234,6 +249,26 @@ } } %>
<% end %> + <% unless @holding.security.offline? %> +
+
+

<%= t(".market_data_label") %>

+

+ <%= t(".last_price_update") %>: <%= @last_price_updated ? l(@last_price_updated, format: :long) : t(".never") %> +

+
+ <%= button_to t(".market_data_sync_button"), + sync_prices_holding_path(@holding), + method: :post, + class: "inline-flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium text-primary bg-gray-200 hover:bg-gray-300 theme-dark:bg-gray-700 theme-dark:hover:bg-gray-600", + data: { loading_button_target: "button" }, + form: { data: { + controller: "loading-button", + action: "submit->loading-button#showLoading", + loading_button_loading_text_value: t(".syncing") + } } %> +
+ <% end %> <% if @holding.cost_basis_locked? %>
diff --git a/app/views/holdings/sync_prices.turbo_stream.erb b/app/views/holdings/sync_prices.turbo_stream.erb new file mode 100644 index 000000000..f2e253d91 --- /dev/null +++ b/app/views/holdings/sync_prices.turbo_stream.erb @@ -0,0 +1,56 @@ +<% unless @provider_error %> + <%= turbo_stream.replace dom_id(@holding, :current_market_price) do %> +
+ <% begin %> + <%= @holding.security.current_price ? format_money(@holding.security.current_price) : t("holdings.show.unknown") %> + <% rescue ActiveRecord::RecordInvalid %> + <%= t("holdings.show.unknown") %> + <% rescue StandardError => e %> + <% logger.error "Error fetching current price for security #{@holding.security.id}: #{e.message}" %> + <% logger.error e.backtrace.first(5).join("\n") %> + <%= t("holdings.show.unknown") %> + <% end %> +
+ <% end %> + <%= turbo_stream.replace dom_id(@holding, :market_value) do %> +
+
<%= t("holdings.show.market_value_label") %>
+
<%= format_money(@holding.amount_money) %>
+
+ <% end %> + <%= turbo_stream.replace dom_id(@holding, :total_return) do %> +
+
<%= t("holdings.show.total_return_label") %>
+ <% if @holding.trend %> +
+ <%= render("shared/trend_change", trend: @holding.trend) %> +
+ <% else %> +
<%= t("holdings.show.unknown") %>
+ <% end %> +
+ <% end %> +<% end %> +<%= turbo_stream.replace dom_id(@holding, :market_data_section) do %> +
+
+

<%= t("holdings.show.market_data_label") %>

+

+ <%= t("holdings.show.last_price_update") %>: <%= @last_price_updated ? l(@last_price_updated, format: :long) : t("holdings.show.never") %> +

+ <% if @provider_error %> +

<%= @provider_error %>

+ <% end %> +
+ <%= button_to t("holdings.show.market_data_sync_button"), + sync_prices_holding_path(@holding), + method: :post, + class: "inline-flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium text-primary bg-gray-200 hover:bg-gray-300 theme-dark:bg-gray-700 theme-dark:hover:bg-gray-600", + data: { loading_button_target: "button" }, + form: { data: { + controller: "loading-button", + action: "submit->loading-button#showLoading", + loading_button_loading_text_value: t("holdings.show.syncing") + } } %> +
+<% end %> diff --git a/app/views/import/confirms/show.html.erb b/app/views/import/confirms/show.html.erb index 8d3122ab2..269aca93e 100644 --- a/app/views/import/confirms/show.html.erb +++ b/app/views/import/confirms/show.html.erb @@ -2,32 +2,65 @@ <%= render "imports/nav", import: @import %> <% end %> -<%= content_for :previous_path, import_clean_path(@import) %> +<%= content_for :previous_path, @import.is_a?(SureImport) ? imports_path : import_clean_path(@import) %> -<% step_idx = (params[:step] || "1").to_i - 1 %> -<% step_mapping_class = @import.mapping_steps[step_idx] %> +<% if @import.is_a?(SureImport) %> +
+
+

<%= t("import.confirms.sure_import.title") %>

+

<%= t("import.confirms.sure_import.description") %>

+
-
-
- <% @import.mapping_steps.each_with_index do |step_mapping_class, idx| %> - <% is_active = step_idx == idx %> - - <%= link_to url_for(step: idx + 1), class: "w-5 h-[3px] #{is_active ? 'bg-gray-900' : 'bg-gray-100'} rounded-xl hover:bg-gray-300 transition-colors duration-200" do %> - Step <%= idx + 1 %> +
+

<%= t("import.confirms.sure_import.summary") %>

+ <% dry_run = @import.dry_run %> + <% sure_summary_empty = dry_run.values.none?(&:positive?) %> + <% if sure_summary_empty %> +

<%= t("import.confirms.sure_import.empty_summary") %>

+ <% else %> +
    + <% dry_run.each do |key, count| %> + <% next if count.zero? %> +
  • + <%= key.to_s.humanize %> + <%= count %> +
  • + <% end %> +
<% end %> - <% end %> +
+ +
+ <%= button_to t("import.confirms.sure_import.publish_button"), publish_import_path(@import), method: :post, class: "btn btn--primary w-full", disabled: !@import.publishable? %> + <%= link_to t("import.confirms.sure_import.cancel"), imports_path, class: "btn btn--ghost w-full text-center" %> +
+
+<% else %> + <% step_idx = (params[:step] || "1").to_i - 1 %> + <% step_mapping_class = @import.mapping_steps[step_idx] %> + +
+
+ <% @import.mapping_steps.each_with_index do |step_mapping_class, idx| %> + <% is_active = step_idx == idx %> + + <%= link_to url_for(step: idx + 1), class: "w-5 h-[3px] #{is_active ? 'bg-gray-900' : 'bg-gray-100'} rounded-xl hover:bg-gray-300 transition-colors duration-200" do %> + Step <%= idx + 1 %> + <% end %> + <% end %> +
+ +
+

+ <%= t(".#{step_mapping_class.name.demodulize.underscore}_title", import_type: @import.type.underscore.humanize) %> +

+

+ <%= t(".#{step_mapping_class.name.demodulize.underscore}_description", import_type: @import.type.underscore.humanize, product: product_name) %> +

+
-
-

- <%= t(".#{step_mapping_class.name.demodulize.underscore}_title", import_type: @import.type.underscore.humanize) %> -

-

- <%= t(".#{step_mapping_class.name.demodulize.underscore}_description", import_type: @import.type.underscore.humanize, product_name: product_name) %> -

+
+ <%= render partial: "import/confirms/mappings", locals: { import: @import, mapping_class: step_mapping_class, step_idx: step_idx } %>
-
- -
- <%= render partial: "import/confirms/mappings", locals: { import: @import, mapping_class: step_mapping_class, step_idx: step_idx } %> -
+<% end %> diff --git a/app/views/import/qif_category_selections/show.html.erb b/app/views/import/qif_category_selections/show.html.erb new file mode 100644 index 000000000..4976d9961 --- /dev/null +++ b/app/views/import/qif_category_selections/show.html.erb @@ -0,0 +1,157 @@ +<%= content_for :header_nav do %> + <%= render "imports/nav", import: @import %> +<% end %> + +<%= content_for :previous_path, import_upload_path(@import) %> + +
+
+

<%= t(".title") %>

+

<%= t(".description", product_name: product_name) %>

+
+ + <%= form_with url: import_qif_category_selection_path(@import), method: :put, class: "space-y-8" do |form| %> + + <%# ── Date format ─────────────────────────────────────────────── %> + <%= tag.div class: "space-y-3", data: { controller: "qif-date-format", qif_date_format_previews_value: @date_previews.to_json } do %> +

<%= t("imports.date_format.heading") %>

+

<%= t("imports.date_format.description") %>

+ + <% if @date_formats.any? %> + <%= form.label :date_format, t("imports.date_format.heading"), class: "sr-only" %> + <%= form.select :date_format, + options_for_select(@date_formats, @import.qif_date_format), + {}, + { class: "w-full rounded-lg border border-secondary bg-container px-3 py-2 text-sm text-primary", + data: { action: "change->qif-date-format#change" } } %> + +
+ <%= t("imports.date_format.preview") %>: + <%= @date_previews[@import.qif_date_format] %> +
+ <% else %> +
+ <%= icon("circle-alert", size: "md", class: "text-destructive shrink-0 mt-0.5") %> +
+

<%= t("imports.date_format.error_title") %>

+

<%= t("imports.date_format.error_description") %>

+
+
+ <% end %> + <% end %> + + <%# ── Split transaction warning ────────────────────────────── %> + <% if @has_split_transactions %> +
+ <%= icon("triangle-alert", size: "md", class: "text-warning shrink-0 mt-0.5") %> +
+

<%= t(".split_warning_title") %>

+

<%= t(".split_warning_description") %>

+
+
+ <% end %> + + <%# ── Categories ─────────────────────────────────────────────── %> + <% if @categories.any? %> +
+
+

<%= t(".categories_heading") %>

+ <%= t(".categories_found", count: @categories.count) %> +
+ +
+
+
+
+
<%= t(".category_name_col") %>
+
<%= t(".transactions_col") %>
+
+ +
+ <% @categories.each_with_index do |category, index| %> + <% is_split = @split_categories.include?(category) %> + + <% end %> +
+
+
+
+ <% end %> + + <%# ── Tags ───────────────────────────────────────────────────── %> + <% if @tags.any? %> +
+
+

<%= t(".tags_heading") %>

+ <%= t(".tags_found", count: @tags.count) %> +
+ +
+
+
+
+
<%= t(".tag_name_col") %>
+
<%= t(".transactions_col") %>
+
+ +
+ <% @tags.each_with_index do |tag, index| %> + + <% end %> +
+
+
+
+ <% end %> + + <%# ── Empty state ─────────────────────────────────────────────── %> + <% if @categories.empty? && @tags.empty? %> +
+ <%= icon("tag", size: "lg", class: "mx-auto mb-2") %> +

<%= t(".empty_state_primary") %>

+

<%= t(".empty_state_secondary") %>

+
+ <% end %> + + <%# ── Submit ──────────────────────────────────────────────────── %> +
+ <%= form.submit t(".submit"), + class: "btn btn-primary w-full md:w-auto", + disabled: @date_formats.empty? %> +
+ + <% end %> +
diff --git a/app/views/import/uploads/show.html.erb b/app/views/import/uploads/show.html.erb index 338654578..cc9a94136 100644 --- a/app/views/import/uploads/show.html.erb +++ b/app/views/import/uploads/show.html.erb @@ -4,77 +4,165 @@ <%= content_for :previous_path, imports_path %> -
- - <%= render "imports/drag_drop_overlay", title: "Drop CSV to upload", subtitle: "Your file will be uploaded automatically" %> +<% if @import.is_a?(SureImport) %> +
+ + <%= render "imports/drag_drop_overlay", title: t("import.uploads.sure_import.drop_title"), subtitle: t("import.uploads.sure_import.drop_subtitle") %> -
-
-

<%= t(".title") %>

-

<%= t(".description") %>

+
+
+

<%= t("import.uploads.sure_import.title") %>

+

<%= t("import.uploads.sure_import.description") %>

+
+ + <%= styled_form_with model: @import, scope: :import, url: import_upload_path(@import), multipart: true, class: "space-y-2", data: { drag_and_drop_import_target: "form" } do |form| %> +
+
+
+ <%= icon("database", size: "lg", class: "mb-4 mx-auto") %> +

+ <%= t("import.uploads.sure_import.browse") %> <%= t("import.uploads.sure_import.browse_hint") %> +

+
+ + + + <%= form.file_field :ndjson_file, class: "hidden", accept: ".ndjson,.json", "data-auto-submit-form-target": "auto", "data-file-upload-target": "input", "data-drag-and-drop-import-target": "input" %> +
+
+ + <%= form.submit t("import.uploads.sure_import.upload_button"), disabled: @import.complete? %> + <% end %>
- <%= render DS::Tabs.new(active_tab: params[:tab] || "csv-upload", url_param_key: "tab", testid: "import-tabs") do |tabs| %> - <% tabs.with_nav do |nav| %> - <% nav.with_btn(id: "csv-upload", label: "Upload CSV") %> - <% nav.with_btn(id: "csv-paste", label: "Copy & Paste") %> - <% end %> +
+ + <%= t("import.uploads.sure_import.hint_html") %> + +
+
+<% elsif @import.is_a?(QifImport) %> + <%# ── QIF upload – fixed format, account required ── %> +
+
+

<%= t(".qif_title") %>

+

<%= t(".qif_description") %>

+
- <% tabs.with_panel(tab_id: "csv-upload") do %> - <%= styled_form_with model: @import, scope: :import, url: import_upload_path(@import), multipart: true, class: "space-y-2", data: { drag_and_drop_import_target: "form" } do |form| %> - <%= form.select :col_sep, Import::SEPARATORS, label: true %> + <%= styled_form_with model: @import, scope: :import, url: import_upload_path(@import), multipart: true, class: "space-y-4" do |form| %> + <%= form.select :account_id, + @import.family.accounts.visible.alphabetically.pluck(:name, :id), + { label: t(".qif_account_label"), include_blank: t(".qif_account_placeholder"), selected: @import.account_id }, + required: true %> - <% if @import.type == "TransactionImport" || @import.type == "TradeImport" %> - <%= form.select :account_id, @import.family.accounts.visible.pluck(:name, :id), { label: "Account (optional)", include_blank: "Multi-account import", selected: @import.account_id } %> - <% end %> - -
-
-
- <%= icon("plus", size: "lg", class: "mb-4 mx-auto") %> -

- Browse to add your CSV file here -

-
- - - - <%= form.file_field :import_file, class: "hidden", "data-auto-submit-form-target": "auto", "data-file-upload-target": "input", "data-drag-and-drop-import-target": "input" %> -
+ - <% if @import.type == "TransactionImport" || @import.type == "TradeImport" %> - <%= form.select :account_id, @import.family.accounts.visible.pluck(:name, :id), { label: "Account (optional)", include_blank: "Multi-account import", selected: @import.account_id } %> - <% end %> - - <%= form.text_area :raw_file_str, - rows: 10, - required: true, - placeholder: "Paste your CSV file contents here", - "data-auto-submit-form-target": "auto" %> - - <%= form.submit "Upload CSV", disabled: @import.complete? %> - <% end %> - <% end %> + <%= form.submit t(".qif_submit"), disabled: @import.complete? %> <% end %>
-
- - <%= link_to "Download a sample CSV", "/imports/#{@import.id}/upload/sample_csv", class: "text-primary underline", data: { turbo: false } %> to see the required CSV format - +<% else %> + <%# ── Standard CSV upload ── %> +
+ + <%= render "imports/drag_drop_overlay", title: "Drop CSV to upload", subtitle: "Your file will be uploaded automatically" %> + +
+
+

<%= t(".title") %>

+

<%= t(".description") %>

+
+ + <%= render DS::Tabs.new(active_tab: params[:tab] || "csv-upload", url_param_key: "tab", testid: "import-tabs") do |tabs| %> + <% tabs.with_nav do |nav| %> + <% nav.with_btn(id: "csv-upload", label: "Upload CSV") %> + <% nav.with_btn(id: "csv-paste", label: "Copy & Paste") %> + <% end %> + + <% tabs.with_panel(tab_id: "csv-upload") do %> + <%= styled_form_with model: @import, scope: :import, url: import_upload_path(@import), multipart: true, class: "space-y-2", data: { drag_and_drop_import_target: "form" } do |form| %> + <%= form.select :col_sep, Import::SEPARATORS, label: true %> + + <% if @import.type == "TransactionImport" || @import.type == "TradeImport" %> + <%= form.select :account_id, @import.family.accounts.visible.alphabetically.pluck(:name, :id), { label: "Account (optional)", include_blank: "Multi-account import", selected: @import.account_id } %> + <% end %> + + + + <%= form.submit "Upload CSV", disabled: @import.complete? %> + <% end %> + <% end %> + + <% tabs.with_panel(tab_id: "csv-paste") do %> + <%= styled_form_with model: @import, scope: :import, url: import_upload_path(@import), multipart: true, class: "space-y-2" do |form| %> + <%= form.select :col_sep, Import::SEPARATORS, label: true %> + + <% if @import.type == "TransactionImport" || @import.type == "TradeImport" %> + <%= form.select :account_id, @import.family.accounts.visible.alphabetically.pluck(:name, :id), { label: "Account (optional)", include_blank: "Multi-account import", selected: @import.account_id } %> + <% end %> + + <%= form.text_area :raw_file_str, + rows: 10, + required: true, + placeholder: "Paste your CSV file contents here", + "data-auto-submit-form-target": "auto" %> + + <%= form.submit "Upload CSV", disabled: @import.complete? %> + <% end %> + <% end %> + <% end %> +
+ +
+ + <%= link_to "Download a sample CSV", "/imports/#{@import.id}/upload/sample_csv", class: "text-primary underline", data: { turbo: false } %> to see the required CSV format + +
-
+<% end %> diff --git a/app/views/imports/_nav.html.erb b/app/views/imports/_nav.html.erb index 43282c5d3..0c9ee5aaa 100644 --- a/app/views/imports/_nav.html.erb +++ b/app/views/imports/_nav.html.erb @@ -1,6 +1,11 @@ <%# locals: (import:) %> -<% steps = if import.is_a?(PdfImport) +<% steps = if import.is_a?(SureImport) + [ + { name: t("imports.steps.upload", default: "Upload"), path: import_upload_path(import), is_complete: import.uploaded?, step_number: 1 }, + { name: t("imports.steps.confirm", default: "Confirm"), path: import_path(import), is_complete: import.complete?, step_number: 2 } + ] +elsif import.is_a?(PdfImport) # PDF imports have a simplified flow: Upload -> Confirm # Upload/Configure/Clean are always complete for processed PDF imports [ @@ -9,6 +14,15 @@ { name: t("imports.steps.clean", default: "Clean"), path: import.configured? ? import_clean_path(import) : nil, is_complete: import.cleaned?, step_number: 3 }, { name: t("imports.steps.confirm", default: "Confirm"), path: import_path(import), is_complete: import.complete?, step_number: 4 } ] +elsif import.is_a?(QifImport) + # QIF imports combine date-format configuration with category/tag selection. + [ + { name: t("imports.steps.upload", default: "Upload"), path: import_upload_path(import), is_complete: import.uploaded?, step_number: 1 }, + { name: t("imports.steps.select", default: "Select"), path: import.uploaded? ? import_qif_category_selection_path(import) : nil, is_complete: import.categories_selected?, step_number: 2 }, + { name: t("imports.steps.clean", default: "Clean"), path: import.uploaded? ? import_clean_path(import) : nil, is_complete: import.cleaned?, step_number: 3 }, + { name: t("imports.steps.map", default: "Map"), key: "Map", path: import_confirm_path(import), is_complete: import.publishable?, step_number: 4 }, + { name: t("imports.steps.confirm", default: "Confirm"), path: import_path(import), is_complete: import.complete?, step_number: 5 } + ].reject { |step| step[:key] == "Map" && import.mapping_steps.empty? } else [ { name: t("imports.steps.upload", default: "Upload"), path: import_upload_path(import), is_complete: import.uploaded?, step_number: 1 }, diff --git a/app/views/imports/_ready.html.erb b/app/views/imports/_ready.html.erb index 9ab7c0eb8..9e2e0236d 100644 --- a/app/views/imports/_ready.html.erb +++ b/app/views/imports/_ready.html.erb @@ -1,4 +1,7 @@ <%# locals: (import:) %> +<% dry_run = import.dry_run %> +<% resources_with_counts = dry_run.select { |_, count| count > 0 }.filter_map { |key, count| [dry_run_resource(key), count] if dry_run_resource(key) } %> +<% import_summary_empty = resources_with_counts.empty? %>

<%= t(".title") %>

@@ -8,32 +11,40 @@
-

item

-

count

+

<%= t(".summary_item_label") %>

+

<%= t(".summary_count_label") %>

- <% import.dry_run.each do |key, count| %> - <% resource = dry_run_resource(key) %> + <% if import_summary_empty %> +
+

<%= t(".empty_summary") %>

+
+ <% else %> + <% resources_with_counts.each_with_index do |(resource, count), index| %> +
+
+ <%= tag.div class: class_names(resource.bg_class, resource.text_class, "w-8 h-8 rounded-full flex justify-center items-center") do %> + <%= icon resource.icon, color: "current" %> + <% end %> -
-
- <%= tag.div class: class_names(resource.bg_class, resource.text_class, "w-8 h-8 rounded-full flex justify-center items-center") do %> - <%= icon resource.icon, color: "current" %> - <% end %> +

<%= resource.label %>

+
-

<%= resource.label %>

+

<%= count %>

-

<%= count %>

-
- - <% if key != import.dry_run.keys.last %> - <%= render "shared/ruler" %> + <% unless index == resources_with_counts.length - 1 %> + <%= render "shared/ruler" %> + <% end %> <% end %> <% end %>
- <%= render DS::Button.new(text: "Publish import", href: publish_import_path(import), full_width: true) %> + <% if import_summary_empty %> + <%= render DS::Button.new(text: t(".back_to_imports"), href: imports_path, variant: :secondary, full_width: true) %> + <% else %> + <%= render DS::Button.new(text: t(".publish_import"), href: publish_import_path(import), method: :post, full_width: true) %> + <% end %>
diff --git a/app/views/imports/new.html.erb b/app/views/imports/new.html.erb index 0b8c1a490..33d07ddfd 100644 --- a/app/views/imports/new.html.erb +++ b/app/views/imports/new.html.erb @@ -2,14 +2,13 @@ <% dialog.with_header(title: t(".title"), subtitle: t(".description")) %> <% dialog.with_body do %> - <% has_accounts = Current.family.accounts.any? %> + <% has_accounts = accessible_accounts.any? %> <% requires_account_message = t(".requires_account") %> -
-

<%= t(".sources") %>

-
    -
  • - <% if @pending_import.present? && (params[:type].nil? || params[:type] == @pending_import.type) %> + <% if @pending_import.present? && params[:type].nil? %> +
    +
      +
    • <%= link_to import_path(@pending_import), class: "flex items-center justify-between p-4 group cursor-pointer", data: { turbo: false } do %>
      @@ -26,98 +25,208 @@ <%= render "shared/ruler" %>
    • - <% end %> +
    +
    + <% end %> - <% if params[:type].nil? || params[:type] == "TransactionImport" %> - <%= render "imports/import_option", - type: "TransactionImport", - icon_name: "file-spreadsheet", - icon_bg_class: "bg-indigo-500/5", - icon_text_class: "text-indigo-500", - label: t(".import_transactions"), - enabled: has_accounts, - disabled_message: requires_account_message %> - <% end %> + <% + import_type = params[:type].presence || @pending_import&.type + active_tab = import_type.present? && !import_type.in?(%w[MintImport QifImport SureImport DocumentImport PdfImport]) ? "raw_data" : "financial_tools" + %> + <%= render DS::Tabs.new(active_tab: active_tab) do |tabs| %> + <% tabs.with_nav do |nav| %> + <% nav.with_btn(id: "financial_tools", label: t(".tab_financial_tools")) %> + <% nav.with_btn(id: "raw_data", label: t(".tab_raw_data")) %> + <% end %> - <% if params[:type].nil? || params[:type] == "TradeImport" %> - <%= render "imports/import_option", - type: "TradeImport", - icon_name: "square-percent", - icon_bg_class: "bg-yellow-500/5", - icon_text_class: "text-yellow-500", - label: t(".import_portfolio"), - enabled: has_accounts, - disabled_message: requires_account_message %> - <% end %> - - <% if params[:type].nil? || params[:type] == "AccountImport" %> - <%= render "imports/import_option", - type: "AccountImport", - icon_name: "building", - icon_bg_class: "bg-violet-500/5", - icon_text_class: "text-violet-500", - label: t(".import_accounts"), - enabled: true %> - <% end %> - - <% if params[:type].nil? || params[:type] == "CategoryImport" %> - <%= render "imports/import_option", - type: "CategoryImport", - icon_name: "shapes", - icon_bg_class: "bg-blue-500/5", - icon_text_class: "text-blue-500", - label: t(".import_categories"), - enabled: true %> - <% end %> - - <% if params[:type].nil? || params[:type] == "RuleImport" %> - <%= render "imports/import_option", - type: "RuleImport", - icon_name: "workflow", - icon_bg_class: "bg-green-500/5", - icon_text_class: "text-green-500", - label: t(".import_rules"), - enabled: true %> - <% end %> - - <% if params[:type].nil? || params[:type] == "MintImport" || params[:type] == "TransactionImport" %> - <%= render "imports/import_option", - type: "MintImport", - image: "mint-logo.jpeg", - label: t(".import_mint"), - enabled: has_accounts, - disabled_message: requires_account_message %> - <% end %> - - <% if (params[:type].nil? || params[:type].in?(%w[DocumentImport PdfImport])) && @document_upload_extensions.any? %> -
  • - <%= styled_form_with url: imports_path, scope: :import, multipart: true, class: "w-full" do |form| %> - <%= form.hidden_field :type, value: "DocumentImport" %> -
  • <% end %> - <%= render "shared/ruler" %> - - <% end %> -
-
+ <% if params[:type].nil? || params[:type] == "SureImport" %> +
  • + <%= styled_form_with url: imports_path, scope: :import, multipart: true, class: "w-full", data: { turbo: false } do |form| %> + <%= form.hidden_field :type, value: "SureImport" %> + + <% end %> + + <%= render "shared/ruler" %> +
  • + <% end %> + + <% if params[:type].nil? || params[:type] == "MintImport" || params[:type] == "TransactionImport" %> + <%= render "imports/import_option", + type: "MintImport", + image: "mint-logo.jpeg", + label: t(".import_mint"), + enabled: true %> + <% end %> + + <% if params[:type].nil? || params[:type] == "QifImport" %> + <%= render "imports/import_option", + type: "QifImport", + icon_name: "file-clock", + icon_bg_class: "bg-teal-500/5", + icon_text_class: "text-teal-500", + label: t(".import_qif"), + enabled: true %> + <% end %> + + <%= render "imports/import_option", + type: "TransactionImport", + icon_name: "bar-chart-2", + icon_bg_class: "bg-gray-500/5", + icon_text_class: "text-gray-400", + label: t(".import_ynab"), + enabled: false, + disabled_message: t(".coming_soon") %> + + <% if (params[:type].nil? || params[:type].in?(%w[DocumentImport PdfImport])) && @document_upload_extensions.any? %> +
  • + <%= styled_form_with url: imports_path, scope: :import, multipart: true, class: "w-full", data: { turbo: false } do |form| %> + <%= form.hidden_field :type, value: "DocumentImport" %> + + <% end %> + + <%= render "shared/ruler" %> +
  • + <% end %> + +
    + <% end %> + + <% tabs.with_panel(tab_id: "raw_data") do %> +
    +
      + <% if @pending_import.present? && params[:type].present? && !params[:type].in?(%w[MintImport QifImport SureImport DocumentImport PdfImport]) %> +
    • + <%= link_to import_path(@pending_import), class: "flex items-center justify-between p-4 group cursor-pointer", data: { turbo: false } do %> +
      +
      + + <%= icon("loader", color: "current") %> + +
      + + <%= t(".resume", type: @pending_import.type.titleize) %> + +
      + <%= icon("chevron-right") %> + <% end %> + + <%= render "shared/ruler" %> +
    • + <% end %> + + <% if params[:type].nil? || params[:type] == "TransactionImport" %> + <%= render "imports/import_option", + type: "TransactionImport", + icon_name: "file-spreadsheet", + icon_bg_class: "bg-indigo-500/5", + icon_text_class: "text-indigo-500", + label: t(".import_transactions"), + enabled: has_accounts, + disabled_message: requires_account_message %> + <% end %> + + <% if params[:type].nil? || params[:type] == "TradeImport" %> + <%= render "imports/import_option", + type: "TradeImport", + icon_name: "square-percent", + icon_bg_class: "bg-yellow-500/5", + icon_text_class: "text-yellow-500", + label: t(".import_portfolio"), + enabled: has_accounts, + disabled_message: requires_account_message %> + <% end %> + + <% if params[:type].nil? || params[:type] == "AccountImport" %> + <%= render "imports/import_option", + type: "AccountImport", + icon_name: "building", + icon_bg_class: "bg-violet-500/5", + icon_text_class: "text-violet-500", + label: t(".import_accounts"), + enabled: true %> + <% end %> + + <% if params[:type].nil? || params[:type] == "CategoryImport" %> + <%= render "imports/import_option", + type: "CategoryImport", + icon_name: "shapes", + icon_bg_class: "bg-blue-500/5", + icon_text_class: "text-blue-500", + label: t(".import_categories"), + enabled: true %> + <% end %> + + <% if params[:type].nil? || params[:type] == "RuleImport" %> + <%= render "imports/import_option", + type: "RuleImport", + icon_name: "workflow", + icon_bg_class: "bg-green-500/5", + icon_text_class: "text-green-500", + label: t(".import_rules"), + enabled: true %> + <% end %> +
    +
    + <% end %> + <% end %> <% end %> <% end %> diff --git a/app/views/indexa_capital_items/_indexa_capital_item.html.erb b/app/views/indexa_capital_items/_indexa_capital_item.html.erb index 30946aef5..915a68650 100644 --- a/app/views/indexa_capital_items/_indexa_capital_item.html.erb +++ b/app/views/indexa_capital_items/_indexa_capital_item.html.erb @@ -49,50 +49,52 @@
    -
    - <% if indexa_capital_item.requires_update? %> - <%= render DS::Link.new( - text: t(".update_credentials"), - icon: "refresh-cw", - variant: "secondary", - href: settings_providers_path, - frame: "_top" - ) %> - <% else %> - <%= icon( - "refresh-cw", - as_button: true, - href: sync_indexa_capital_item_path(indexa_capital_item), - disabled: indexa_capital_item.syncing? - ) %> - <% end %> - - <%= render DS::Menu.new do |menu| %> - <% if unlinked_count > 0 %> - <% menu.with_item( - variant: "link", - text: t(".setup_action"), - icon: "settings", - href: setup_accounts_indexa_capital_item_path(indexa_capital_item), - frame: :modal + <% if Current.user&.admin? %> +
    + <% if indexa_capital_item.requires_update? %> + <%= render DS::Link.new( + text: t(".update_credentials"), + icon: "refresh-cw", + variant: "secondary", + href: settings_providers_path, + frame: "_top" + ) %> + <% else %> + <%= icon( + "refresh-cw", + as_button: true, + href: sync_indexa_capital_item_path(indexa_capital_item), + disabled: indexa_capital_item.syncing? ) %> <% end %> - <% menu.with_item( - variant: "link", - text: t(".update_credentials"), - icon: "cable", - href: settings_providers_path(manage: "1") - ) %> - <% menu.with_item( - variant: "button", - text: t(".delete"), - icon: "trash-2", - href: indexa_capital_item_path(indexa_capital_item), - method: :delete, - confirm: CustomConfirm.for_resource_deletion(indexa_capital_item.name, high_severity: true) - ) %> - <% end %> -
    + + <%= render DS::Menu.new do |menu| %> + <% if unlinked_count > 0 %> + <% menu.with_item( + variant: "link", + text: t(".setup_action"), + icon: "settings", + href: setup_accounts_indexa_capital_item_path(indexa_capital_item), + frame: :modal + ) %> + <% end %> + <% menu.with_item( + variant: "link", + text: t(".update_credentials"), + icon: "cable", + href: settings_providers_path(manage: "1") + ) %> + <% menu.with_item( + variant: "button", + text: t(".delete"), + icon: "trash-2", + href: indexa_capital_item_path(indexa_capital_item), + method: :delete, + confirm: CustomConfirm.for_resource_deletion(indexa_capital_item.name, high_severity: true) + ) %> + <% end %> +
    + <% end %> <% unless indexa_capital_item.scheduled_for_deletion? %> diff --git a/app/views/investment_activity/_quick_edit_badge.html.erb b/app/views/investment_activity/_quick_edit_badge.html.erb index 4db357ef2..05c243894 100644 --- a/app/views/investment_activity/_quick_edit_badge.html.erb +++ b/app/views/investment_activity/_quick_edit_badge.html.erb @@ -35,6 +35,7 @@ activity_labels = entryable.is_a?(Trade) ? Trade::ACTIVITY_LABELS : Transaction::ACTIVITY_LABELS entryable_type = entryable.is_a?(Trade) ? "Trade" : "Transaction" convert_url = entryable.is_a?(Transaction) ? convert_to_trade_transaction_path(entryable) : nil + income_trade = entryable.is_a?(Trade) && %w[Dividend Interest].include?(label) %>
    > - - diff --git a/app/views/investments/_form.html.erb b/app/views/investments/_form.html.erb index 4fc706e33..be4004d27 100644 --- a/app/views/investments/_form.html.erb +++ b/app/views/investments/_form.html.erb @@ -1,7 +1,9 @@ <%# locals: (account:, url:) %> <%= render "accounts/form", account: account, url: url do |form| %> - <%= form.select :subtype, - grouped_options_for_select(Investment.subtypes_grouped_for_select(currency: Current.family.currency), account.subtype), - { label: true, prompt: t("investments.form.subtype_prompt"), include_blank: t("investments.form.none") } %> + <%= form.fields_for :accountable do |investment_form| %> + <%= investment_form.select :subtype, + grouped_options_for_select(Investment.subtypes_grouped_for_select(currency: Current.family.currency), account.accountable.subtype), + { label: true, prompt: t("investments.form.subtype_prompt"), include_blank: t("investments.form.none") } %> + <% end %> <% end %> diff --git a/app/views/layouts/_privacy_mode_check.html.erb b/app/views/layouts/_privacy_mode_check.html.erb new file mode 100644 index 000000000..6e9299901 --- /dev/null +++ b/app/views/layouts/_privacy_mode_check.html.erb @@ -0,0 +1,5 @@ + diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 3736f47a9..88db5847a 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -23,7 +23,7 @@ end %> <%= render "layouts/shared/htmldoc" do %>
    @@ -55,7 +55,19 @@ end %> <%= image_tag "logomark-color.svg", class: "w-9 h-9 mx-auto" %> <% end %> - <%= render "users/user_menu", user: Current.user, placement: "bottom-end", offset: 12, intro_mode: intro_mode %> +
    + + <%= render "users/user_menu", user: Current.user, placement: "bottom-end", offset: 12, intro_mode: intro_mode %> +
    <%# DESKTOP - Left navbar %> @@ -139,7 +151,21 @@ end %> <%= render "layouts/shared/breadcrumbs", breadcrumbs: @breadcrumbs %> <% end %>
    - <%= icon("panel-right", as_button: true, data: { action: "app-layout#toggleRightSidebar" }) %> + +
    + + + <%= icon("panel-right", as_button: true, data: { action: "app-layout#toggleRightSidebar" }) %> +
    <% end %> diff --git a/app/views/layouts/imports.html.erb b/app/views/layouts/imports.html.erb index 09f15cf04..e68034e10 100644 --- a/app/views/layouts/imports.html.erb +++ b/app/views/layouts/imports.html.erb @@ -1,5 +1,5 @@ <%= render "layouts/shared/htmldoc" do %> -
    +
    <%= render DS::Link.new( variant: "icon", diff --git a/app/views/layouts/shared/_head.html.erb b/app/views/layouts/shared/_head.html.erb index b96ce2c61..91ea7910d 100644 --- a/app/views/layouts/shared/_head.html.erb +++ b/app/views/layouts/shared/_head.html.erb @@ -8,9 +8,9 @@ <%= combobox_style_tag %> - <%= yield :plaid_link %> <%= javascript_importmap_tags %> <%= render "layouts/dark_mode_check" %> + <%= render "layouts/privacy_mode_check" %> <%= turbo_refreshes_with method: :morph, scroll: :preserve %> diff --git a/app/views/layouts/wizard.html.erb b/app/views/layouts/wizard.html.erb index 3e82583e1..2a9896fb0 100644 --- a/app/views/layouts/wizard.html.erb +++ b/app/views/layouts/wizard.html.erb @@ -1,5 +1,5 @@ <%= render "layouts/shared/htmldoc" do %> -
    +
    <% if content_for?(:prev_nav) %> <%= yield :prev_nav %> diff --git a/app/views/loans/_form.html.erb b/app/views/loans/_form.html.erb index 48d7a0ce5..646e2fa00 100644 --- a/app/views/loans/_form.html.erb +++ b/app/views/loans/_form.html.erb @@ -28,6 +28,12 @@ label: t("loans.form.term_months"), placeholder: t("loans.form.term_months_placeholder") %>
    + +
    + <%= loan_form.select :subtype, + Loan::SUBTYPES.map { |k, v| [v[:long], k] }, + { label: true, prompt: t("loans.form.subtype_prompt"), include_blank: t("loans.form.none") } %> +
    <% end %>
    <% end %> diff --git a/app/views/lunchflow_items/_lunchflow_item.html.erb b/app/views/lunchflow_items/_lunchflow_item.html.erb index f03b8755c..a1aec313b 100644 --- a/app/views/lunchflow_items/_lunchflow_item.html.erb +++ b/app/views/lunchflow_items/_lunchflow_item.html.erb @@ -54,26 +54,28 @@
    -
    - <% if Rails.env.development? %> - <%= icon( - "refresh-cw", - as_button: true, - href: sync_lunchflow_item_path(lunchflow_item) - ) %> - <% end %> + <% if Current.user&.admin? %> +
    + <% if Rails.env.development? %> + <%= icon( + "refresh-cw", + as_button: true, + href: sync_lunchflow_item_path(lunchflow_item) + ) %> + <% end %> - <%= render DS::Menu.new do |menu| %> - <% menu.with_item( - variant: "button", - text: t(".delete"), - icon: "trash-2", - href: lunchflow_item_path(lunchflow_item), - method: :delete, - confirm: CustomConfirm.for_resource_deletion(lunchflow_item.name, high_severity: true) - ) %> - <% end %> -
    + <%= render DS::Menu.new do |menu| %> + <% menu.with_item( + variant: "button", + text: t(".delete"), + icon: "trash-2", + href: lunchflow_item_path(lunchflow_item), + method: :delete, + confirm: CustomConfirm.for_resource_deletion(lunchflow_item.name, high_severity: true) + ) %> + <% end %> +
    + <% end %> <% unless lunchflow_item.scheduled_for_deletion? %> diff --git a/app/views/mercury_items/_mercury_item.html.erb b/app/views/mercury_items/_mercury_item.html.erb index 7988a6b28..0159551e8 100644 --- a/app/views/mercury_items/_mercury_item.html.erb +++ b/app/views/mercury_items/_mercury_item.html.erb @@ -54,26 +54,28 @@
    -
    - <% if Rails.env.development? %> - <%= icon( - "refresh-cw", - as_button: true, - href: sync_mercury_item_path(mercury_item) - ) %> - <% end %> + <% if Current.user&.admin? %> +
    + <% if Rails.env.development? %> + <%= icon( + "refresh-cw", + as_button: true, + href: sync_mercury_item_path(mercury_item) + ) %> + <% end %> - <%= render DS::Menu.new do |menu| %> - <% menu.with_item( - variant: "button", - text: t(".delete"), - icon: "trash-2", - href: mercury_item_path(mercury_item), - method: :delete, - confirm: CustomConfirm.for_resource_deletion(mercury_item.name, high_severity: true) - ) %> - <% end %> -
    + <%= render DS::Menu.new do |menu| %> + <% menu.with_item( + variant: "button", + text: t(".delete"), + icon: "trash-2", + href: mercury_item_path(mercury_item), + method: :delete, + confirm: CustomConfirm.for_resource_deletion(mercury_item.name, high_severity: true) + ) %> + <% end %> +
    + <% end %> <% unless mercury_item.scheduled_for_deletion? %> diff --git a/app/views/oidc_accounts/link.html.erb b/app/views/oidc_accounts/link.html.erb index c97c3cd3f..c4bf1aaf3 100644 --- a/app/views/oidc_accounts/link.html.erb +++ b/app/views/oidc_accounts/link.html.erb @@ -54,7 +54,7 @@ <% if @allow_account_creation %> <%= render DS::Button.new( - text: t("oidc_accounts.link.submit_create"), + text: @pending_invitation ? t("oidc_accounts.link.submit_accept_invitation") : t("oidc_accounts.link.submit_create"), href: create_user_oidc_account_path, full_width: true, variant: :primary, @@ -76,4 +76,4 @@ variant: :default, class: "font-medium text-sm text-primary hover:underline transition" ) %> -
    \ No newline at end of file +
    diff --git a/app/views/onboardings/show.html.erb b/app/views/onboardings/show.html.erb index 694bc97e8..38d90b378 100644 --- a/app/views/onboardings/show.html.erb +++ b/app/views/onboardings/show.html.erb @@ -39,8 +39,7 @@ data-onboarding-household-name-label-value="<%= t(".household_name") %>" data-onboarding-household-name-placeholder-value="<%= t(".household_name_placeholder") %>" data-onboarding-group-name-label-value="<%= t(".group_name") %>" - data-onboarding-group-name-placeholder-value="<%= t(".group_name_placeholder") %>" - > + data-onboarding-group-name-placeholder-value="<%= t(".group_name_placeholder") %>">

    <%= t(".moniker_prompt", product_name: product_name) %>

    - <%= render DS::Link.new( - icon: "plus", - text: t("pages.dashboard.new"), - href: new_account_path, - frame: :modal, - class: "hidden lg:inline-flex" - ) %> - - <%= render DS::Link.new( - variant: "icon-inverse", +
    + <%= render DS::Link.new( icon: "plus", + text: t("pages.dashboard.new"), href: new_account_path, frame: :modal, - class: "rounded-full lg:hidden" + class: "hidden lg:inline-flex" ) %> + + <%= render DS::Link.new( + variant: "icon-inverse", + icon: "plus", + href: new_account_path, + frame: :modal, + class: "rounded-full lg:hidden" + ) %> +
    <% end %> -
    - <% if Current.family.accounts.any? %> +
    gap-6 pb-6 lg:pb-12" data-controller="dashboard-sortable" data-action="dragover->dashboard-sortable#dragOver drop->dashboard-sortable#drop" role="list" aria-label="Dashboard sections"> + <% if accessible_accounts.any? %> <% @dashboard_sections.each do |section| %> <% next unless section[:visible] %>
    " data-dashboard-section-section-key-value="<%= section[:key] %>" data-dashboard-section-collapsed-value="<%= Current.user.dashboard_section_collapsed?(section[:key]) %>" draggable="true" @@ -49,6 +51,7 @@ touchstart->dashboard-sortable#touchStart touchmove->dashboard-sortable#touchMove touchend->dashboard-sortable#touchEnd + touchcancel->dashboard-sortable#touchEnd keydown->dashboard-sortable#handleKeyDown">
    diff --git a/app/views/pages/dashboard/_balance_sheet.html.erb b/app/views/pages/dashboard/_balance_sheet.html.erb index 5bacd50c5..0f8710581 100644 --- a/app/views/pages/dashboard/_balance_sheet.html.erb +++ b/app/views/pages/dashboard/_balance_sheet.html.erb @@ -11,7 +11,7 @@ <% if classification_group.account_groups.any? %> · - <%= classification_group.total_money.format(precision: 0) %> + <%= classification_group.total_money.format(precision: 0) %> <% end %>
    @@ -29,7 +29,7 @@

    <%= account_group.name %>

    -

    <%= number_to_percentage(account_group.weight, precision: 0) %>

    +

    <%= number_to_percentage(account_group.weight, precision: 0) %>

    <% end %>
    @@ -67,7 +67,7 @@
    -

    <%= format_money(account_group.total_money) %>

    +

    <%= format_money(account_group.total_money) %>

    @@ -92,7 +92,7 @@
    -

    <%= format_money(account.balance_money) %>

    +

    <%= format_money(account.balance_money) %>

    diff --git a/app/views/pages/dashboard/_cashflow_sankey.html.erb b/app/views/pages/dashboard/_cashflow_sankey.html.erb index fe15a4fdc..5ec8de5ee 100644 --- a/app/views/pages/dashboard/_cashflow_sankey.html.erb +++ b/app/views/pages/dashboard/_cashflow_sankey.html.erb @@ -15,7 +15,7 @@ data-controller="sankey-chart" data-sankey-chart-data-value="<%= sankey_data.to_json %>" data-sankey-chart-currency-symbol-value="<%= sankey_data[:currency_symbol] %>" - class="w-full h-full">
    + class="w-full h-full privacy-sensitive">
    <%= render DS::Dialog.new(id: "cashflow-expanded-dialog", auto_open: false, width: "custom", disable_frame: true, content_class: "!w-[96vw] max-w-[1650px]", data: { action: "close->cashflow-expand#restore" }) do |dialog| %> <% dialog.with_header(title: t("pages.dashboard.cashflow_sankey.title")) %> @@ -25,7 +25,7 @@ data-controller="sankey-chart" data-sankey-chart-data-value="<%= sankey_data.to_json %>" data-sankey-chart-currency-symbol-value="<%= sankey_data[:currency_symbol] %>" - class="w-full h-full">
    + class="w-full h-full privacy-sensitive">
    <% end %> <% end %> diff --git a/app/views/pages/dashboard/_group_weight.html.erb b/app/views/pages/dashboard/_group_weight.html.erb index c00655a23..d3bf08e5c 100644 --- a/app/views/pages/dashboard/_group_weight.html.erb +++ b/app/views/pages/dashboard/_group_weight.html.erb @@ -8,5 +8,5 @@
    " style="background-color: <%= color %>;">
    <% end %>
    -

    <%= number_to_percentage(effective_weight, precision: 2) %>

    +

    <%= number_to_percentage(effective_weight, precision: 2) %>

    diff --git a/app/views/pages/dashboard/_investment_summary.html.erb b/app/views/pages/dashboard/_investment_summary.html.erb index f10f0d776..bfae9c502 100644 --- a/app/views/pages/dashboard/_investment_summary.html.erb +++ b/app/views/pages/dashboard/_investment_summary.html.erb @@ -8,7 +8,7 @@

    <%= t(".title") %>

    -

    +

    <%= format_money(investment_statement.portfolio_value_money) %>

    @@ -16,7 +16,7 @@ <% if trend %>
    <%= t(".total_return") %>: - + <%= format_money(Money.new(trend.value, Current.family.currency)) %> (<%= trend.percent_formatted %>) @@ -27,50 +27,57 @@ <% holdings = investment_statement.top_holdings(limit: 5) %> <% if holdings.any? %> -
    -
    -
    <%= t(".holding") %>
    -
    <%= t(".weight") %>
    -
    <%= t(".value") %>
    -
    <%= t(".return") %>
    -
    - -
    - <% holdings.each_with_index do |holding, idx| %> -
    "> -
    - <% if holding.security.logo_url.present? %> - <%= holding.ticker %> - <% else %> -
    - <%= holding.ticker[0..1] %> +
    +
    + + + + + + + + + + + <% holdings.each_with_index do |holding, idx| %> + "> + -
    - <%= number_to_percentage(holding.weight || 0, precision: 1) %> -
    + -
    - <%= format_money(holding.amount_money) %> -
    + -
    - <% if holding.trend %> - - <%= holding.trend.percent_formatted %> - - <% else %> - - - <% end %> -
    - - <% end %> + + + <% end %> + +
    <%= t(".holding") %><%= t(".weight") %><%= t(".value") %><%= t(".return") %>
    +
    + <% if holding.security.logo_url.present? %> + <%= holding.ticker %> + <% else %> +
    + <%= holding.ticker.to_s.first(2).presence || "—" %> +
    + <% end %> +
    +

    <%= holding.ticker %>

    +

    <%= truncate(holding.name, length: 20) %>

    +
    - <% end %> -
    -

    <%= holding.ticker %>

    -

    <%= truncate(holding.name, length: 20) %>

    -
    - +
    + <%= number_to_percentage(holding.weight || 0, precision: 1) %> + + <%= format_money(holding.amount_money) %> + + <% if holding.trend %> + + <%= holding.trend.percent_formatted %> + + <% else %> + - + <% end %> +
    <% end %> @@ -90,7 +97,7 @@

    <%= t(".contributions") %>

    -

    <%= format_money(totals.contributions) %>

    +

    <%= format_money(totals.contributions) %>

    @@ -99,7 +106,7 @@

    <%= t(".withdrawals") %>

    -

    <%= format_money(totals.withdrawals) %>

    +

    <%= format_money(totals.withdrawals) %>

    diff --git a/app/views/pages/dashboard/_net_worth_chart.html.erb b/app/views/pages/dashboard/_net_worth_chart.html.erb index 2ac67416b..1a5f81d68 100644 --- a/app/views/pages/dashboard/_net_worth_chart.html.erb +++ b/app/views/pages/dashboard/_net_worth_chart.html.erb @@ -5,7 +5,7 @@
    <% if series.trend.present? %> -

    "> +

    "> <%= series.trend.current.format %>

    <%= render partial: "shared/trend_change", locals: { trend: series.trend, comparison_label: period.comparison_label } %> @@ -26,7 +26,7 @@ <% if series.any? %>
    <% else %> diff --git a/app/views/pages/dashboard/_outflows_donut.html.erb b/app/views/pages/dashboard/_outflows_donut.html.erb index 5079dcc9d..8e563e528 100644 --- a/app/views/pages/dashboard/_outflows_donut.html.erb +++ b/app/views/pages/dashboard/_outflows_donut.html.erb @@ -31,7 +31,7 @@ <%= t("pages.dashboard.outflows_donut.total_outflows") %>
    -
    +
    <%= format_money Money.new(outflows_data[:total], outflows_data[:currency]) %>
    @@ -41,11 +41,11 @@

    <%= category[:name] %>

    -

    +

    <%= outflows_data[:currency_symbol] %><%= number_with_delimiter(category[:amount], delimiter: ",") %>

    -

    <%= category[:percentage] %>%

    +

    <%= category[:percentage] %>%

    <% end %> @@ -94,8 +94,8 @@ <%= category[:name] %>
    - <%= format_money Money.new(category[:amount], category[:currency]) %> - <%= category[:percentage] %>% + <%= format_money Money.new(category[:amount], category[:currency]) %> + <%= category[:percentage] %>%
    <% end %> diff --git a/app/views/pages/intro.html.erb b/app/views/pages/intro.html.erb index 072a76068..30effdcc8 100644 --- a/app/views/pages/intro.html.erb +++ b/app/views/pages/intro.html.erb @@ -1,7 +1,7 @@ <% content_for :page_header do %>

    Welcome!

    -
    +
    <% end %> diff --git a/app/views/pending_duplicate_merges/new.html.erb b/app/views/pending_duplicate_merges/new.html.erb new file mode 100644 index 000000000..580603f62 --- /dev/null +++ b/app/views/pending_duplicate_merges/new.html.erb @@ -0,0 +1,96 @@ +<%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) %> + <% dialog.with_body do %> +
    +
    +
    + <%= icon "alert-triangle", size: "md", class: "text-warning flex-shrink-0 mt-0.5" %> +
    +

    <%= t(".warning_title") %>

    +

    <%= t(".warning_description") %>

    +
    +
    +
    + + <%= styled_form_with( + url: transaction_pending_duplicate_merges_path(@transaction.entry), + scope: :pending_duplicate_merges, + class: "space-y-6", + data: { turbo_frame: :_top } + ) do |f| %> + +
    +

    <%= icon "clock", size: "sm" %> <%= t(".pending_transaction") %>

    +
    +
    +
    +

    <%= @transaction.entry.name %>

    +

    + <%= @transaction.entry.account.name %> + • <%= I18n.l(@transaction.entry.date, format: :long) %> + • <%= number_to_currency(@transaction.entry.amount.abs, unit: Money::Currency.new(@transaction.entry.currency).symbol) %> +

    +
    +
    +
    +
    + +
    + <%= icon "arrow-down", class: "text-secondary" %> +
    + + +
    +

    <%= t(".select_posted") %>

    + + <%= turbo_frame_tag "posted_transaction_candidates" do %> + <% if @potential_duplicates.any? %> +
    + <% @potential_duplicates.each do |entry| %> + + <% end %> +
    + +
    +

    + <%= t(".showing_range", start: @range_start, end: @range_end) %> +

    +
    + <% if @offset > 0 %> + <%= link_to t(".previous"), + new_transaction_pending_duplicate_merges_path(@transaction.entry, offset: [@offset - 10, 0].max), + class: "text-xs text-link hover:underline", + data: { turbo_frame: "posted_transaction_candidates" } %> + <% end %> + <% if @has_more %> + <%= link_to t(".next"), + new_transaction_pending_duplicate_merges_path(@transaction.entry, offset: @offset + 10), + class: "text-xs text-link hover:underline", + data: { turbo_frame: "posted_transaction_candidates" } %> + <% end %> +
    +
    + <% else %> +
    +

    <%= t(".no_candidates") %>

    +
    + <% end %> + <% end %> +
    + + <% if @potential_duplicates.any? %> + <%= f.submit t(".submit_button"), class: "w-full" %> + <% end %> + <% end %> +
    + <% end %> +<% end %> diff --git a/app/views/plaid_items/_auto_link_opener.html.erb b/app/views/plaid_items/_auto_link_opener.html.erb index b25884954..7e9c76950 100644 --- a/app/views/plaid_items/_auto_link_opener.html.erb +++ b/app/views/plaid_items/_auto_link_opener.html.erb @@ -1,9 +1,5 @@ <%# locals: (link_token:, region:, item_id:, is_update: false) %> -<% content_for :plaid_link, flush: true do %> - <%= javascript_include_tag "https://cdn.plaid.com/link/v2/stable/link-initialize.js" %> -<% end %> - <%= tag.div data: { controller: "plaid", plaid_link_token_value: link_token, diff --git a/app/views/plaid_items/_plaid_item.html.erb b/app/views/plaid_items/_plaid_item.html.erb index 2c8e26c15..e7fd2254f 100644 --- a/app/views/plaid_items/_plaid_item.html.erb +++ b/app/views/plaid_items/_plaid_item.html.erb @@ -46,34 +46,36 @@
    -
    - <% if plaid_item.requires_update? %> - <%= render DS::Link.new( - text: t(".update"), - icon: "refresh-cw", - variant: "secondary", - href: edit_plaid_item_path(plaid_item), - frame: "modal" - ) %> - <% elsif Rails.env.development? %> - <%= icon( - "refresh-cw", - as_button: true, - href: sync_plaid_item_path(plaid_item) - ) %> - <% end %> + <% if Current.user&.admin? %> +
    + <% if plaid_item.requires_update? %> + <%= render DS::Link.new( + text: t(".update"), + icon: "refresh-cw", + variant: "secondary", + href: edit_plaid_item_path(plaid_item), + frame: "modal" + ) %> + <% elsif Rails.env.development? %> + <%= icon( + "refresh-cw", + as_button: true, + href: sync_plaid_item_path(plaid_item) + ) %> + <% end %> - <%= render DS::Menu.new do |menu| %> - <% menu.with_item( - variant: "button", - text: t(".delete"), - icon: "trash-2", - href: plaid_item_path(plaid_item), - method: :delete, - confirm: CustomConfirm.for_resource_deletion(plaid_item.name, high_severity: true) - ) %> - <% end %> -
    + <%= render DS::Menu.new do |menu| %> + <% menu.with_item( + variant: "button", + text: t(".delete"), + icon: "trash-2", + href: plaid_item_path(plaid_item), + method: :delete, + confirm: CustomConfirm.for_resource_deletion(plaid_item.name, high_severity: true) + ) %> + <% end %> +
    + <% end %> <% unless plaid_item.scheduled_for_deletion? %> diff --git a/app/views/properties/_overview_fields.html.erb b/app/views/properties/_overview_fields.html.erb index c3fd55f34..0fe59023d 100644 --- a/app/views/properties/_overview_fields.html.erb +++ b/app/views/properties/_overview_fields.html.erb @@ -6,7 +6,6 @@ placeholder: "Vacation home", required: true %> - <%= form.hidden_field :accountable_type, value: "Property" %> <%= form.fields_for :accountable do |property_form| %> diff --git a/app/views/pwa/manifest.json.erb b/app/views/pwa/manifest.json.erb index e7960c255..820c221a8 100644 --- a/app/views/pwa/manifest.json.erb +++ b/app/views/pwa/manifest.json.erb @@ -2,6 +2,11 @@ "name": "<%= j product_name %>", "short_name": "<%= j product_name %>", "icons": [ + { + "src": "/android-chrome-192x192.png", + "type": "image/png", + "sizes": "192x192" + }, { "src": "/logo-pwa.png", "type": "image/png", diff --git a/app/views/recurring_transactions/_projected_transaction.html.erb b/app/views/recurring_transactions/_projected_transaction.html.erb index 0f7b8b59d..3659e3fe2 100644 --- a/app/views/recurring_transactions/_projected_transaction.html.erb +++ b/app/views/recurring_transactions/_projected_transaction.html.erb @@ -56,6 +56,6 @@
    <% display_amount = recurring_transaction.manual? && recurring_transaction.expected_amount_avg.present? ? recurring_transaction.expected_amount_avg : recurring_transaction.amount %> - <%= content_tag :p, format_money(-Money.new(display_amount, recurring_transaction.currency)), class: ["font-medium", display_amount.negative? ? "text-success" : "text-subdued"] %> + <%= content_tag :p, format_money(-Money.new(display_amount, recurring_transaction.currency)), class: ["font-medium", "privacy-sensitive", display_amount.negative? ? "text-success" : "text-subdued"] %>
    diff --git a/app/views/recurring_transactions/index.html.erb b/app/views/recurring_transactions/index.html.erb index 06374fef6..7447cb317 100644 --- a/app/views/recurring_transactions/index.html.erb +++ b/app/views/recurring_transactions/index.html.erb @@ -59,7 +59,7 @@
    <% if @recurring_transactions.empty? %>
    -
    +
    <%= icon "repeat", size: "xl" %>

    <%= t("recurring_transactions.empty.title") %>

    @@ -128,11 +128,11 @@ <% end %>
    - "> + "> <% if recurring_transaction.manual? && recurring_transaction.has_amount_variance? %>
    "> ~ - <%= format_money(-recurring_transaction.expected_amount_avg_money) %> + <%= format_money(-recurring_transaction.expected_amount_avg_money) %>
    <% else %> <%= format_money(-recurring_transaction.amount_money) %> diff --git a/app/views/reports/_breakdown_table.html.erb b/app/views/reports/_breakdown_table.html.erb index 2034ba4cb..ff3141557 100644 --- a/app/views/reports/_breakdown_table.html.erb +++ b/app/views/reports/_breakdown_table.html.erb @@ -11,35 +11,34 @@
    <%= icon(icon_name, class: "w-5 h-5") %> <%= t(title_key) %>: - <%= Money.new(total, Current.family.currency).format %> + <%= Money.new(total, Current.family.currency).format %>
    -
    -
    -
    <%= t("reports.transactions_breakdown.table.category") %>
    -
    - <%= link_to reports_path(amount_sort_params), class: "inline-flex items-center gap-1 hover:text-primary" do %> - <%= t("reports.transactions_breakdown.table.amount") %> - <% if current_sort_by == "amount" %> - <%= icon(current_sort_direction == "desc" ? "chevron-down" : "chevron-up", class: "w-3 h-3") %> +
    + + + + + + + + + <% groups.each_with_index do |group, idx| %> <%= render "reports/category_row", item: group, total: total, color_class: color_class, - level: :category - %> - <% if idx < groups.size - 1 %> - <%= render "shared/ruler", classes: "mx-3 lg:mx-4" %> - <% end %> + level: :category, + show_border: idx < groups.size - 1 || group[:subcategories].present? %> <%# Render subcategories if present %> <% if group[:subcategories].present? && group[:subcategories].any? %> <% group[:subcategories].each_with_index do |subcategory, sub_idx| %> @@ -47,15 +46,13 @@ item: subcategory, total: total, color_class: color_class, - level: :subcategory - %> - <% if sub_idx < group[:subcategories].size - 1 %> - <%= render "shared/ruler", classes: "mx-3 lg:mx-4" %> - <% end %> + level: :subcategory, + show_border: sub_idx < group[:subcategories].size - 1 %> <% end %> <% end %> <% end %> - + +
    <%= t("reports.transactions_breakdown.table.category") %> + <%= link_to reports_path(amount_sort_params), class: "inline-flex items-center gap-1 hover:text-primary" do %> + <%= t("reports.transactions_breakdown.table.amount") %> + <% if current_sort_by == "amount" %> + <%= icon(current_sort_direction == "desc" ? "chevron-down" : "chevron-up", class: "w-3 h-3") %> + <% end %> <% end %> - <% end %> - -
    <%= t("reports.transactions_breakdown.table.percentage") %>
    - - -
    +
    <%= t("reports.transactions_breakdown.table.percentage") %>
    diff --git a/app/views/reports/_budget_performance.html.erb b/app/views/reports/_budget_performance.html.erb index a66a50a89..23b93bc3c 100644 --- a/app/views/reports/_budget_performance.html.erb +++ b/app/views/reports/_budget_performance.html.erb @@ -63,13 +63,13 @@
    <%= t("reports.budget_performance.spent") %>: - + <%= Money.new(budget_item[:actual], Current.family.currency).format %>
    <%= t("reports.budget_performance.budgeted") %>: - + <%= Money.new(budget_item[:budgeted], Current.family.currency).format %>
    @@ -78,12 +78,12 @@
    <% if budget_item[:remaining] >= 0 %> <%= t("reports.budget_performance.remaining") %>: - + <%= Money.new(budget_item[:remaining], Current.family.currency).format %> <% else %> <%= t("reports.budget_performance.over_by") %>: - + <%= Money.new(budget_item[:remaining].abs, Current.family.currency).format %> <% end %> diff --git a/app/views/reports/_category_row.html.erb b/app/views/reports/_category_row.html.erb index ac37dafe6..dbc15afca 100644 --- a/app/views/reports/_category_row.html.erb +++ b/app/views/reports/_category_row.html.erb @@ -1,50 +1,53 @@ <% percentage = total.zero? ? 0 : (item[:total].to_f / total * 100).round(1) is_sub = level == :subcategory + show_border = local_assigns.fetch(:show_border, false) %> -
    "> -
    - <% if is_sub %> -
    - <%= icon "corner-down-right" %> -
    - <% end %> - <% if item[:category_icon] %> -
    - <%= icon(item[:category_icon], color: "current", size: "sm") %> -
    - <% else %> - <%= render DS::FilledIcon.new( - variant: :text, - hex_color: item[:category_color], - text: item[:category_name], - size: "md", - rounded: true - ) %> - <% end %> - - <%= item[:category_name] %> - - - (<%= t("reports.transactions_breakdown.table.entries", count: item[:count]) %>) - -
    +"> + "> +
    + <% if is_sub %> +
    + <%= icon "corner-down-right" %> +
    + <% end %> + <% if item[:category_icon] %> +
    + <%= icon(item[:category_icon], color: "current", size: "sm") %> +
    + <% else %> + <%= render DS::FilledIcon.new( + variant: :text, + hex_color: item[:category_color], + text: item[:category_name], + size: "md", + rounded: true + ) %> + <% end %> + + <%= item[:category_name] %> + + + (<%= t("reports.transactions_breakdown.table.entries", count: item[:count]) %>) + +
    + -
    - + + <%= Money.new(item[:total], Current.family.currency).format %> -
    + -
    + <%= percentage %>% -
    -
    + + diff --git a/app/views/reports/_investment_flows.html.erb b/app/views/reports/_investment_flows.html.erb index 99a01273a..83db092e2 100644 --- a/app/views/reports/_investment_flows.html.erb +++ b/app/views/reports/_investment_flows.html.erb @@ -11,7 +11,7 @@ <%= icon("trending-up", size: "sm", class: "text-green-600") %>

    Contributions

    -
    +
    <%= format_money(investment_flows.contributions) %>

    Money added to investments

    @@ -23,7 +23,7 @@ <%= icon("trending-down", size: "sm", class: "text-orange-600") %>

    Withdrawals

    -
    +
    <%= format_money(investment_flows.withdrawals) %>

    Money withdrawn from investments

    @@ -35,7 +35,7 @@ <%= icon("arrow-right-left", size: "sm", class: "text-primary") %>

    Net Flow

    -
    +
    <%= format_money(investment_flows.net_flow) %>

    Total net change

    diff --git a/app/views/reports/_investment_performance.html.erb b/app/views/reports/_investment_performance.html.erb index aa63cbf4d..7434ab923 100644 --- a/app/views/reports/_investment_performance.html.erb +++ b/app/views/reports/_investment_performance.html.erb @@ -10,7 +10,7 @@ <%= icon("briefcase", size: "sm") %> <%= t("reports.investment_performance.portfolio_value") %>
    -

    +

    <%= format_money(investment_metrics[:portfolio_value]) %>

    @@ -22,7 +22,7 @@ <%= t("reports.investment_performance.total_return") %>
    <% if investment_metrics[:unrealized_trend] %> -

    +

    <%= format_money(Money.new(investment_metrics[:unrealized_trend].value, Current.family.currency)) %> (<%= investment_metrics[:unrealized_trend].percent_formatted %>)

    @@ -37,7 +37,7 @@ <%= icon("arrow-down-to-line", size: "sm") %> <%= t("reports.investment_performance.contributions") %>
    -

    +

    <%= format_money(investment_metrics[:period_contributions]) %>

    @@ -48,7 +48,7 @@ <%= icon("arrow-up-from-line", size: "sm") %> <%= t("reports.investment_performance.withdrawals") %>
    -

    +

    <%= format_money(investment_metrics[:period_withdrawals]) %>

    @@ -58,48 +58,54 @@ <% if investment_metrics[:top_holdings].any? %>

    <%= t("reports.investment_performance.top_holdings") %>

    -
    -
    -
    <%= t("reports.investment_performance.holding") %>
    -
    <%= t("reports.investment_performance.weight") %>
    -
    <%= t("reports.investment_performance.value") %>
    -
    <%= t("reports.investment_performance.return") %>
    -
    -
    - <% investment_metrics[:top_holdings].each_with_index do |holding, idx| %> -
    -
    - <% if holding.security.brandfetch_icon_url.present? %> - <%= holding.ticker %> - <% elsif holding.security.logo_url.present? %> - <%= holding.ticker %> - <% else %> -
    - <%= holding.ticker[0..1] %> +
    +
    + + + + + + + + + + + <% investment_metrics[:top_holdings].each_with_index do |holding, idx| %> + "> + + + + + <% end %> - <% end %> + +
    <%= t("reports.investment_performance.holding") %><%= t("reports.investment_performance.weight") %><%= t("reports.investment_performance.value") %><%= t("reports.investment_performance.return") %>
    +
    + <% if holding.security.brandfetch_icon_url.present? %> + <%= holding.ticker %> + <% elsif holding.security.logo_url.present? %> + <%= holding.ticker %> + <% else %> +
    + <%= holding.ticker[0..1] %> +
    + <% end %> +
    +

    <%= holding.ticker %>

    +

    <%= truncate(holding.name, length: 25) %>

    +
    - <% end %> -
    -

    <%= holding.ticker %>

    -

    <%= truncate(holding.name, length: 25) %>

    -
    - -
    <%= number_to_percentage(holding.weight || 0, precision: 1) %>
    -
    <%= format_money(holding.amount_money) %>
    -
    - <% if holding.trend %> - - <%= holding.trend.percent_formatted %> - - <% else %> - <%= t("reports.investment_performance.no_data") %> - <% end %> -
    - - <% if idx < investment_metrics[:top_holdings].size - 1 %> - <%= render "shared/ruler", classes: "mx-3 lg:mx-4" %> +
    <%= number_to_percentage(holding.weight || 0, precision: 1) %><%= format_money(holding.amount_money) %> + <% if holding.trend %> + + <%= holding.trend.percent_formatted %> + + <% else %> + <%= t("reports.investment_performance.no_data") %> + <% end %> +
    +
    @@ -117,7 +123,7 @@ "> <%= t("accounts.tax_treatments.#{treatment}") %> - + <%= format_money(data[:total_gain]) %>
    @@ -125,11 +131,11 @@
    <%= t("reports.investment_performance.unrealized_gains") %> - <%= format_money(data[:unrealized_gain]) %> + <%= format_money(data[:unrealized_gain]) %>
    <%= t("reports.investment_performance.realized_gains") %> - <%= format_money(data[:realized_gain]) %> + <%= format_money(data[:realized_gain]) %>
    @@ -205,7 +211,7 @@

    <%= account.short_subtype_label %>

    -

    <%= format_money(account.balance_money) %>

    +

    <%= format_money(account.balance_money) %>

    <% end %> <% end %>
    diff --git a/app/views/reports/_net_worth.html.erb b/app/views/reports/_net_worth.html.erb index e3c8f01df..153090bf8 100644 --- a/app/views/reports/_net_worth.html.erb +++ b/app/views/reports/_net_worth.html.erb @@ -4,7 +4,7 @@ <%# Current Net Worth %>

    <%= t("reports.net_worth.current_net_worth") %>

    -

    "> +

    "> <%= net_worth_metrics[:current_net_worth].format %>

    @@ -14,7 +14,7 @@

    <%= t("reports.net_worth.period_change") %>

    <% if net_worth_metrics[:trend] %> <% trend = net_worth_metrics[:trend] %> -

    +

    <%= trend.value.format(signify_positive: true) %>

    @@ -29,9 +29,9 @@

    <%= t("reports.net_worth.assets_vs_liabilities") %>

    - <%= net_worth_metrics[:total_assets].format %> + <%= net_worth_metrics[:total_assets].format %> - - <%= net_worth_metrics[:total_liabilities].format %> + <%= net_worth_metrics[:total_liabilities].format %>
    @@ -40,43 +40,55 @@
    <%# Assets Summary %>
    -
    -
    <%= t("reports.net_worth.total_assets") %>
    -
    -
    - <% net_worth_metrics[:asset_groups].each_with_index do |group, idx| %> -
    -
    <%= group[:name] %>
    -
    <%= group[:total].format %>
    -
    - <% if idx < net_worth_metrics[:asset_groups].size - 1 %> - <%= render "shared/ruler", classes: "mx-3 lg:mx-4" %> +
    + + + + + + + + + <% net_worth_metrics[:asset_groups].each_with_index do |group, idx| %> + "> + + + <% end %> - <% end %> - <% if net_worth_metrics[:asset_groups].empty? %> -

    <%= t("reports.net_worth.no_assets") %>

    - <% end %> + <% if net_worth_metrics[:asset_groups].empty? %> + + + + <% end %> + +
    <%= t("reports.net_worth.total_assets") %><%= t("reports.transactions_breakdown.table.amount") %>
    <%= group[:name] %><%= group[:total].format %>
    <%= t("reports.net_worth.no_assets") %>
    <%# Liabilities Summary %>
    -
    -
    <%= t("reports.net_worth.total_liabilities") %>
    -
    -
    - <% net_worth_metrics[:liability_groups].each_with_index do |group, idx| %> -
    -
    <%= group[:name] %>
    -
    <%= group[:total].format %>
    -
    - <% if idx < net_worth_metrics[:liability_groups].size - 1 %> - <%= render "shared/ruler", classes: "mx-3 lg:mx-4" %> +
    + + + + + + + + + <% net_worth_metrics[:liability_groups].each_with_index do |group, idx| %> + "> + + + <% end %> - <% end %> - <% if net_worth_metrics[:liability_groups].empty? %> -

    <%= t("reports.net_worth.no_liabilities") %>

    - <% end %> + <% if net_worth_metrics[:liability_groups].empty? %> + + + + <% end %> + +
    <%= t("reports.net_worth.total_liabilities") %><%= t("reports.transactions_breakdown.table.amount") %>
    <%= group[:name] %><%= group[:total].format %>
    <%= t("reports.net_worth.no_liabilities") %>
    diff --git a/app/views/reports/_summary_dashboard.html.erb b/app/views/reports/_summary_dashboard.html.erb index 7ad7ffb8e..927cfd2a7 100644 --- a/app/views/reports/_summary_dashboard.html.erb +++ b/app/views/reports/_summary_dashboard.html.erb @@ -11,12 +11,12 @@
    -

    +

    <%= metrics[:current_income].format %>

    <% if metrics[:income_change] %> -
    +
    <% if metrics[:income_change] >= 0 %> <%= icon("arrow-up", size: "sm") %> @@ -48,12 +48,12 @@
    -

    +

    <%= metrics[:current_expenses].format %>

    <% if metrics[:expense_change] %> -
    +
    <% if metrics[:expense_change] >= 0 %> <%= icon("arrow-up", class: "w-4 h-4 text-destructive") %> @@ -85,7 +85,7 @@
    -

    "> +

    "> <%= metrics[:net_savings].format %>

    @@ -108,7 +108,7 @@
    <% if metrics[:budget_percent] %> -

    +

    <%= metrics[:budget_percent] %>%

    diff --git a/app/views/reports/_trends_insights.html.erb b/app/views/reports/_trends_insights.html.erb index 397de8ed1..a0111fa28 100644 --- a/app/views/reports/_trends_insights.html.erb +++ b/app/views/reports/_trends_insights.html.erb @@ -8,41 +8,46 @@ <% if trends_data.any? %>
    -
    -
    <%= t("reports.trends.month") %>
    -
    <%= t("reports.trends.income") %>
    -
    <%= t("reports.trends.expenses") %>
    -
    <%= t("reports.trends.net") %>
    -
    <%= t("reports.trends.savings_rate") %>
    -
    -
    +
    + + + + + + + + + + + <% trends_data.each_with_index do |trend, idx| %> -
    -
    "> - <%= trend[:month] %> - <% if trend[:is_current_month] %> - (<%= t("reports.trends.current") %>) - <% end %> -
    -
    +
    "> + + + + + + <% end %> - + +
    <%= t("reports.trends.month") %><%= t("reports.trends.income") %><%= t("reports.trends.expenses") %><%= t("reports.trends.net") %><%= t("reports.trends.savings_rate") %>
    +
    @@ -54,21 +59,21 @@

    <%= t("reports.trends.avg_monthly_income") %>

    -

    +

    <%= Money.new(avg_income, Current.family.currency).format %>

    <%= t("reports.trends.avg_monthly_expenses") %>

    -

    +

    <%= Money.new(avg_expenses, Current.family.currency).format %>

    <%= t("reports.trends.avg_monthly_savings") %>

    -

    "> +

    "> <%= Money.new(avg_net, Current.family.currency).format %>

    diff --git a/app/views/reports/index.html.erb b/app/views/reports/index.html.erb index 888271567..d3bd12b37 100644 --- a/app/views/reports/index.html.erb +++ b/app/views/reports/index.html.erb @@ -153,7 +153,7 @@ <%= icon("grip-vertical", size: "sm") %>
    -
    +
    <%= render partial: section[:partial], locals: section[:locals] %>
    diff --git a/app/views/settings/_settings_nav.html.erb b/app/views/settings/_settings_nav.html.erb index 90608e652..bc4f9dcd0 100644 --- a/app/views/settings/_settings_nav.html.erb +++ b/app/views/settings/_settings_nav.html.erb @@ -6,6 +6,7 @@ nav_sections = [ { label: t(".accounts_label"), path: accounts_path, icon: "layers" }, { label: t(".bank_sync_label"), path: settings_bank_sync_path, icon: "banknote" }, { label: t(".preferences_label"), path: settings_preferences_path, icon: "bolt" }, + { label: t(".appearance_label"), path: settings_appearance_path, icon: "palette" }, { label: t(".profile_label"), path: settings_profile_path, icon: "circle-user" }, { label: t(".security_label"), path: settings_security_path, icon: "shield-check" }, { label: t(".payment_label"), path: settings_payment_path, icon: "circle-dollar-sign", if: !self_hosted? && Current.family.can_manage_subscription? } diff --git a/app/views/settings/appearances/show.html.erb b/app/views/settings/appearances/show.html.erb new file mode 100644 index 000000000..f22096236 --- /dev/null +++ b/app/views/settings/appearances/show.html.erb @@ -0,0 +1,71 @@ +<%= content_for :page_title, t(".page_title") %> + +<%= settings_section title: t(".theme_title"), subtitle: t(".theme_subtitle") do %> +
    + <%= form_with model: @user, class: "flex flex-col md:flex-row justify-between items-center gap-4", id: "theme_form", + data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "change" } do |form| %> + <%= form.hidden_field :redirect_to, value: "appearance" %> + + <% theme_option_class = "text-center transition-all duration-200 p-3 rounded-lg hover:bg-surface-hover cursor-pointer [&:has(input:checked)]:bg-surface-hover [&:has(input:checked)]:border [&:has(input:checked)]:border-primary [&:has(input:checked)]:shadow-xs" %> + + <% [ + { value: "light", image: "light-mode-preview.png" }, + { value: "dark", image: "dark-mode-preview.png" }, + { value: "system", image: "system-mode-preview.png" } + ].each do |theme| %> + <%= form.label :"theme_#{theme[:value]}", class: "group" do %> +
    + <%= image_tag(theme[:image], alt: "#{theme[:value].titleize} Theme Preview", class: "max-h-44 mb-2") %> +
    "> + <%= form.radio_button :theme, theme[:value], checked: @user.theme == theme[:value], class: "sr-only", + data: { auto_submit_form_target: "auto", autosubmit_trigger_event: "change", action: "theme#updateTheme" } %> + <%= t(".theme_#{theme[:value]}") %> +
    +
    + <% end %> + <% end %> + <% end %> +
    +<% end %> + +<%= settings_section title: t(".dashboard_title"), subtitle: t(".dashboard_subtitle") do %> +
    + <%= form_with url: settings_appearance_path, method: :patch, + class: "p-3", + data: { controller: "auto-submit-form" } do |f| %> +
    +
    +

    <%= t(".dashboard_two_column_title") %>

    +

    <%= t(".dashboard_two_column_description") %>

    +
    + <%= render DS::Toggle.new( + id: "user_dashboard_two_column", + name: "user[dashboard_two_column]", + checked: @user.dashboard_two_column?, + data: { auto_submit_form_target: "auto" } + ) %> +
    + <% end %> +
    +<% end %> + +<%= settings_section title: t(".transactions_title"), subtitle: t(".transactions_subtitle") do %> +
    + <%= form_with url: settings_appearance_path, method: :patch, + class: "p-3", + data: { controller: "auto-submit-form" } do |f| %> +
    +
    +

    <%= t(".split_grouped_title") %>

    +

    <%= t(".split_grouped_description") %>

    +
    + <%= render DS::Toggle.new( + id: "user_show_split_grouped", + name: "user[show_split_grouped]", + checked: @user.show_split_grouped?, + data: { auto_submit_form_target: "auto" } + ) %> +
    + <% end %> +
    +<% end %> diff --git a/app/views/settings/hostings/_assistant_settings.html.erb b/app/views/settings/hostings/_assistant_settings.html.erb new file mode 100644 index 000000000..082fddde7 --- /dev/null +++ b/app/views/settings/hostings/_assistant_settings.html.erb @@ -0,0 +1,112 @@ +
    +
    +

    <%= t(".title") %>

    + <% if ENV["ASSISTANT_TYPE"].present? %> +

    <%= t(".env_notice", type: ENV["ASSISTANT_TYPE"]) %>

    + <% else %> +

    <%= t(".description") %>

    + <% end %> +
    + + <% effective_type = ENV["ASSISTANT_TYPE"].presence || Current.family.assistant_type %> + + <%= styled_form_with model: Current.family, + url: settings_hosting_path, + method: :patch, + class: "space-y-4", + data: { + controller: "auto-submit-form", + "auto-submit-form-trigger-event-value": "change" + } do |form| %> + <%= form.select :assistant_type, + options_for_select( + [ + [t(".type_builtin"), "builtin"], + [t(".type_external"), "external"] + ], + effective_type + ), + { label: t(".type_label") }, + { disabled: ENV["ASSISTANT_TYPE"].present?, + data: { "auto-submit-form-target": "auto" } } %> + <% end %> + <% if effective_type == "external" %> +
    + <% if Assistant::External.configured? %> + + <%= t(".external_configured") %> + <% else %> + + <%= t(".external_not_configured") %> + <% end %> +
    + + <% if ENV["EXTERNAL_ASSISTANT_URL"].present? && ENV["EXTERNAL_ASSISTANT_TOKEN"].present? %> +

    <%= t(".env_configured_external") %>

    + <% end %> + + <% if Assistant::External.configured? && !ENV["EXTERNAL_ASSISTANT_URL"].present? %> +
    +
    +

    <%= t(".disconnect_title") %>

    +

    <%= t(".disconnect_description") %>

    +
    + <%= button_to t(".disconnect_button"), + disconnect_external_assistant_settings_hosting_path, + method: :delete, + class: "bg-red-600 fg-inverse text-sm font-medium rounded-lg px-4 py-2 whitespace-nowrap", + data: { turbo_confirm: { + title: t(".confirm_disconnect.title"), + body: t(".confirm_disconnect.body"), + accept: t(".disconnect_button"), + acceptClass: "w-full bg-red-600 fg-inverse rounded-xl text-center p-[10px] border mb-2" + }} %> +
    + <% end %> + + <%= styled_form_with model: Setting.new, + url: settings_hosting_path, + method: :patch, + class: "space-y-4", + data: { + controller: "auto-submit-form", + "auto-submit-form-trigger-event-value": "blur" + } do |form| %> + <%= form.text_field :external_assistant_url, + label: t(".url_label"), + placeholder: t(".url_placeholder"), + value: Assistant::External.config.url, + autocomplete: "off", + autocapitalize: "none", + spellcheck: "false", + inputmode: "url", + disabled: ENV["EXTERNAL_ASSISTANT_URL"].present?, + data: { "auto-submit-form-target": "auto" } %> +

    <%= t(".url_help") %>

    + + <%= form.password_field :external_assistant_token, + label: t(".token_label"), + placeholder: t(".token_placeholder"), + value: (Assistant::External.config.token.present? ? "********" : nil), + autocomplete: "off", + autocapitalize: "none", + spellcheck: "false", + inputmode: "text", + disabled: ENV["EXTERNAL_ASSISTANT_TOKEN"].present?, + data: { "auto-submit-form-target": "auto" } %> +

    <%= t(".token_help") %>

    + + <%= form.text_field :external_assistant_agent_id, + label: t(".agent_id_label"), + placeholder: t(".agent_id_placeholder"), + value: Assistant::External.config.agent_id, + autocomplete: "off", + autocapitalize: "none", + spellcheck: "false", + inputmode: "text", + disabled: ENV["EXTERNAL_ASSISTANT_AGENT_ID"].present?, + data: { "auto-submit-form-target": "auto" } %> +

    <%= t(".agent_id_help") %>

    + <% end %> + <% end %> +
    diff --git a/app/views/settings/hostings/_invite_code_settings.html.erb b/app/views/settings/hostings/_invite_code_settings.html.erb index 14e4439e3..cb02f7757 100644 --- a/app/views/settings/hostings/_invite_code_settings.html.erb +++ b/app/views/settings/hostings/_invite_code_settings.html.erb @@ -40,6 +40,29 @@
    <% if Setting.onboarding_state == "invite_only" %> +
    +
    +

    <%= t(".default_family_title") %>

    +

    <%= t(".default_family_description") %>

    +
    + + <%= styled_form_with model: Setting.new, + url: settings_hosting_path, + method: :patch, + data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "change" } do |form| %> +
    + <%= form.select :invite_only_default_family_id, + options_for_select( + [ [ t(".default_family_none"), "" ] ] + + Family.all.map { |f| [ f.name, f.id ] }, + Setting.invite_only_default_family_id + ), + { label: false }, + { data: { auto_submit_form_target: "auto" } } %> +
    + <% end %> +
    +
    <%= t(".generated_tokens") %> diff --git a/app/views/settings/hostings/show.html.erb b/app/views/settings/hostings/show.html.erb index 00b60c823..354cf86a4 100644 --- a/app/views/settings/hostings/show.html.erb +++ b/app/views/settings/hostings/show.html.erb @@ -1,4 +1,7 @@ <%= content_for :page_title, t(".title") %> +<%= settings_section title: t(".ai_assistant") do %> + <%= render "settings/hostings/assistant_settings" %> +<% end %> <%= settings_section title: t(".general") do %>
    <%= render "settings/hostings/openai_settings" %> @@ -19,8 +22,10 @@ <%= settings_section title: t(".sync_settings") do %> <%= render "settings/hostings/sync_settings" %> <% end %> -<%= settings_section title: t(".invites") do %> - <%= render "settings/hostings/invite_code_settings" %> +<% if Current.user.super_admin? %> + <%= settings_section title: t(".invites") do %> + <%= render "settings/hostings/invite_code_settings" %> + <% end %> <% end %> <%= settings_section title: t(".danger_zone") do %> <%= render "settings/hostings/danger_zone_settings" %> diff --git a/app/views/settings/payments/show.html.erb b/app/views/settings/payments/show.html.erb index b395545a3..ac0184cea 100644 --- a/app/views/settings/payments/show.html.erb +++ b/app/views/settings/payments/show.html.erb @@ -13,7 +13,7 @@
    <% if @family.has_active_subscription? %>

    - Currently on the <%= @family.subscription.name %>.
    + Currently on the <%= @family.subscription.name %>.
    <% if @family.next_payment_date %> <% if @family.subscription_pending_cancellation? %> @@ -25,7 +25,7 @@

    <% elsif @family.trialing? %>

    - Currently using the open demo of <%= product_name %>
    + Currently using the open demo of <%= product_name %>
    (Data will be deleted in <%= @family.days_left_in_trial %> days) diff --git a/app/views/settings/preferences/show.html.erb b/app/views/settings/preferences/show.html.erb index a9f44e029..22faaf010 100644 --- a/app/views/settings/preferences/show.html.erb +++ b/app/views/settings/preferences/show.html.erb @@ -57,30 +57,21 @@

    <% end %> -<%= settings_section title: t(".theme_title"), subtitle: t(".theme_subtitle") do %> -
    - <%= form_with model: @user, class: "flex flex-col md:flex-row justify-between items-center gap-4", id: "theme_form", - data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "change" } do |form| %> - <%= form.hidden_field :redirect_to, value: "preferences" %> - - <% theme_option_class = "text-center transition-all duration-200 p-3 rounded-lg hover:bg-surface-hover cursor-pointer [&:has(input:checked)]:bg-surface-hover [&:has(input:checked)]:border [&:has(input:checked)]:border-primary [&:has(input:checked)]:shadow-xs" %> - - <% [ - { value: "light", image: "light-mode-preview.png" }, - { value: "dark", image: "dark-mode-preview.png" }, - { value: "system", image: "system-mode-preview.png" } - ].each do |theme| %> - <%= form.label :"theme_#{theme[:value]}", class: "group" do %> -
    - <%= image_tag(theme[:image], alt: "#{theme[:value].titleize} Theme Preview", class: "max-h-44 mb-2") %> -
    "> - <%= form.radio_button :theme, theme[:value], checked: @user.theme == theme[:value], class: "sr-only", - data: { auto_submit_form_target: "auto", autosubmit_trigger_event: "change", action: "theme#updateTheme" } %> - <%= t(".theme_#{theme[:value]}") %> -
    -
    +<% if Current.user.admin? %> + <%= settings_section title: t(".sharing_title", moniker: family_moniker), subtitle: t(".sharing_subtitle", moniker: family_moniker_downcase) do %> +
    + <%= styled_form_with model: @user, class: "space-y-4", data: { controller: "auto-submit-form" } do |form| %> + <%= form.hidden_field :redirect_to, value: "preferences" %> + <%= form.fields_for :family do |family_form| %> + <%= family_form.select :default_account_sharing, + [ + [t(".sharing_shared"), "shared"], + [t(".sharing_private"), "private"] + ], + { label: t(".sharing_default_label") }, + { data: { auto_submit_form_target: "auto" } } %> <% end %> <% end %> - <% end %> -
    +
    + <% end %> <% end %> diff --git a/app/views/settings/providers/_binance_panel.html.erb b/app/views/settings/providers/_binance_panel.html.erb new file mode 100644 index 000000000..05378904a --- /dev/null +++ b/app/views/settings/providers/_binance_panel.html.erb @@ -0,0 +1,106 @@ +
    + <% items = local_assigns[:binance_items] || @binance_items || Current.family.binance_items.active.ordered %> + +
    +

    <%= t("settings.providers.binance_panel.setup_instructions") %>

    +
      +
    1. <%= t("settings.providers.binance_panel.step1_html").html_safe %>
    2. +
    3. <%= t("settings.providers.binance_panel.step2") %>
    4. +
    5. <%= t("settings.providers.binance_panel.step3") %>
    6. +
    +

    <%= t("settings.providers.binance_panel.no_withdraw_warning") %>

    +
    + +
    +

    <%= t("settings.providers.binance_panel.ip_hint_title") %>

    +

    <%= t("settings.providers.binance_panel.ip_hint_body") %>

    + <% server_ip = ENV["BINANCE_EGRESS_IP"].presence %> + <% if server_ip %> + <%= server_ip %> + <% else %> +

    <%= t("settings.providers.binance_panel.ip_hint_contact_admin") %>

    + <% end %> +
    + + <% error_msg = local_assigns[:error_message] || @error_message %> + <% if error_msg.present? %> +
    +

    <%= error_msg %>

    +
    + <% end %> + + <% if items.any? %> +
    + <% items.each do |item| %> +
    +
    +
    + <%= icon "coins", size: "md", class: "text-[#F0B90B]" %> +
    +
    +

    <%= item.name %>

    +

    + <% if item.syncing? %> + <%= t("settings.providers.binance_panel.syncing") %> + <% else %> + <%= item.sync_status_summary %> + <% end %> +

    +
    +
    +
    + <%= button_to sync_binance_item_path(item), + method: :post, + class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-secondary hover:text-primary border border-secondary rounded-lg hover:border-primary", + disabled: item.syncing? do %> + <%= icon "refresh-cw", size: "sm" %> + <%= t("settings.providers.binance_panel.sync") %> + <% end %> + <%= button_to binance_item_path(item), + method: :delete, + class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-destructive hover:bg-destructive/10 rounded-lg", + data: { turbo_confirm: t("settings.providers.binance_panel.disconnect_confirm") } do %> + <%= icon "trash-2", size: "sm" %> + <% end %> +
    +
    + <% end %> +
    + <% else %> + <% + binance_item = Current.family.binance_items.build(name: "Binance") + %> + + <%= styled_form_with model: binance_item, + url: binance_items_path, + scope: :binance_item, + method: :post, + data: { turbo: true }, + class: "space-y-3" do |form| %> + <%= form.text_field :api_key, + label: t("settings.providers.binance_panel.api_key_label"), + placeholder: t("settings.providers.binance_panel.api_key_placeholder"), + type: :password %> + + <%= form.text_field :api_secret, + label: t("settings.providers.binance_panel.api_secret_label"), + placeholder: t("settings.providers.binance_panel.api_secret_placeholder"), + type: :password %> + +
    + <%= form.submit t("settings.providers.binance_panel.connect_button"), + class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover focus:outline-none focus:ring-2 focus:ring-gray-900 focus:ring-offset-2 transition-colors" %> +
    + <% end %> + <% end %> + +
    + <% if items.any? %> +
    +

    <%= t("settings.providers.binance_panel.status_connected") %>

    + <% else %> +
    +

    <%= t("settings.providers.binance_panel.status_not_connected") %>

    + <% end %> +
    +
    diff --git a/app/views/settings/providers/_enable_banking_panel.html.erb b/app/views/settings/providers/_enable_banking_panel.html.erb index e1568a8f4..1ca388cea 100644 --- a/app/views/settings/providers/_enable_banking_panel.html.erb +++ b/app/views/settings/providers/_enable_banking_panel.html.erb @@ -6,6 +6,7 @@
  • Select your country code from the dropdown below
  • Enter your Application ID and paste your Client Certificate (including the private key)
  • Click Save Configuration, then use "Add Connection" to link your bank
  • +
  • <%= t("settings.providers.enable_banking_panel.callback_url_instruction", callback_url: enable_banking_callback_url) %>
  • Field descriptions:

    @@ -24,10 +25,12 @@ <% end %> <% - enable_banking_item = Current.family.enable_banking_items.first_or_initialize(name: "Enable Banking Connection") + # Use local family variable if available (e.g., from Sidekiq broadcast), otherwise fall back to Current.family (HTTP requests) + family = local_assigns[:family] || Current.family + enable_banking_item = family.enable_banking_items.first_or_initialize(name: "Enable Banking Connection") is_new_record = enable_banking_item.new_record? # Check if there are any authenticated connections (have session_id) - has_authenticated_connections = Current.family.enable_banking_items.where.not(session_id: nil).exists? + has_authenticated_connections = family.enable_banking_items.where.not(session_id: nil).exists? %> <%= styled_form_with model: enable_banking_item, @@ -100,7 +103,7 @@
    <% end %> - <% items = local_assigns[:enable_banking_items] || @enable_banking_items || Current.family.enable_banking_items.where.not(client_certificate: nil) %> + <% items = local_assigns[:enable_banking_items] || @enable_banking_items || family.enable_banking_items.where.not(client_certificate: nil) %> <% if items&.any? %> <% # Find the first item with valid session to use for "Add Connection" button diff --git a/app/views/settings/providers/_lunchflow_panel.html.erb b/app/views/settings/providers/_lunchflow_panel.html.erb index 1c1eee30d..65696e83e 100644 --- a/app/views/settings/providers/_lunchflow_panel.html.erb +++ b/app/views/settings/providers/_lunchflow_panel.html.erb @@ -2,7 +2,7 @@

    Setup instructions:

      -
    1. Visit Lunch Flow to get your API key
    2. +
    3. Visit Lunch Flow to get your API key
    4. Paste your API key below and click the Save button
    5. After a successful connection, go to the Accounts tab to set up new accounts and link them to your existing ones
    diff --git a/app/views/settings/providers/show.html.erb b/app/views/settings/providers/show.html.erb index 3e5603e39..4a9d76bdc 100644 --- a/app/views/settings/providers/show.html.erb +++ b/app/views/settings/providers/show.html.erb @@ -67,13 +67,19 @@ <% end %> + <%= settings_section title: "Binance (beta)", collapsible: true, open: false do %> + + <%= render "settings/providers/binance_panel" %> + + <% end %> + <%= settings_section title: "SnapTrade (beta)", collapsible: true, open: false, auto_open_param: "manage" do %> <%= render "settings/providers/snaptrade_panel" %> <% end %> - <%= settings_section title: "Indexa Capital", collapsible: true, open: false do %> + <%= settings_section title: "Indexa Capital (alpha)", collapsible: true, open: false do %> <%= render "settings/providers/indexa_capital_panel" %> diff --git a/app/views/shared/_money_field.html.erb b/app/views/shared/_money_field.html.erb index 787798c44..41776c28f 100644 --- a/app/views/shared/_money_field.html.erb +++ b/app/views/shared/_money_field.html.erb @@ -7,7 +7,10 @@ end currency = Money::Currency.new(currency_value || options[:default_currency] || "USD") %> -
    +
    data-money-field-precision-value="<%= options[:precision] %>"<% end %> + <% if options[:step].present? %>data-money-field-step-value="<%= options[:step] %>"<% end %>> <% if options[:label_tooltip] %>
    <%= form.label options[:label] || t(".label"), class: "form-field__label" do %> diff --git a/app/views/shared/_transaction_type_tabs.html.erb b/app/views/shared/_transaction_type_tabs.html.erb index 820c23071..720960871 100644 --- a/app/views/shared/_transaction_type_tabs.html.erb +++ b/app/views/shared/_transaction_type_tabs.html.erb @@ -19,7 +19,7 @@ <% end %> <% end %> - <%= link_to new_transfer_path, + <%= link_to new_transfer_path(from_account_id: account_id), data: { turbo_frame: :modal }, class: "flex-1 min-w-0 flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-sm #{active_tab == 'transfer' ? 'bg-container text-primary shadow-sm' : 'hover:bg-container text-subdued hover:text-primary hover:shadow-sm'}" do %> <%= icon "arrow-right-left" %> diff --git a/app/views/shared/_trend_change.html.erb b/app/views/shared/_trend_change.html.erb index ad9c44cf8..7da471193 100644 --- a/app/views/shared/_trend_change.html.erb +++ b/app/views/shared/_trend_change.html.erb @@ -1,6 +1,6 @@ <%# locals: { trend:, comparison_label: nil } %> -

    +

    <% if trend.direction.flat? %> <%= t(".no_change") %><%= " #{comparison_label}" if defined?(comparison_label) && comparison_label.present? %> <% else %> diff --git a/app/views/simplefin_items/_simplefin_item.html.erb b/app/views/simplefin_items/_simplefin_item.html.erb index b9f6ea48d..0daf4af2a 100644 --- a/app/views/simplefin_items/_simplefin_item.html.erb +++ b/app/views/simplefin_items/_simplefin_item.html.erb @@ -143,44 +143,46 @@

    -
    - <% if simplefin_item.requires_update? %> - <%= render DS::Link.new( - text: t(".update"), - icon: "refresh-cw", - variant: "secondary", - href: edit_simplefin_item_path(simplefin_item), - frame: "modal" - ) %> - <% else %> - <%= icon( - "refresh-cw", - as_button: true, - href: sync_simplefin_item_path(simplefin_item), - disabled: simplefin_item.syncing? - ) %> - <% end %> - - <%= render DS::Menu.new do |menu| %> - <% if unlinked_count.to_i > 0 %> - <% menu.with_item( - variant: "link", - text: t(".setup_accounts_menu"), - icon: "settings", - href: setup_accounts_simplefin_item_path(simplefin_item), - frame: :modal + <% if Current.user&.admin? %> +
    + <% if simplefin_item.requires_update? %> + <%= render DS::Link.new( + text: t(".update"), + icon: "refresh-cw", + variant: "secondary", + href: edit_simplefin_item_path(simplefin_item), + frame: "modal" + ) %> + <% else %> + <%= icon( + "refresh-cw", + as_button: true, + href: sync_simplefin_item_path(simplefin_item), + disabled: simplefin_item.syncing? ) %> <% end %> - <% menu.with_item( - variant: "button", - text: t(".delete"), - icon: "trash-2", - href: simplefin_item_path(simplefin_item), - method: :delete, - confirm: CustomConfirm.for_resource_deletion(simplefin_item.name, high_severity: true) - ) %> - <% end %> -
    + + <%= render DS::Menu.new do |menu| %> + <% if unlinked_count.to_i > 0 %> + <% menu.with_item( + variant: "link", + text: t(".setup_accounts_menu"), + icon: "settings", + href: setup_accounts_simplefin_item_path(simplefin_item), + frame: :modal + ) %> + <% end %> + <% menu.with_item( + variant: "button", + text: t(".delete"), + icon: "trash-2", + href: simplefin_item_path(simplefin_item), + method: :delete, + confirm: CustomConfirm.for_resource_deletion(simplefin_item.name, high_severity: true) + ) %> + <% end %> +
    + <% end %> <% unless simplefin_item.scheduled_for_deletion? %> diff --git a/app/views/snaptrade_items/_snaptrade_item.html.erb b/app/views/snaptrade_items/_snaptrade_item.html.erb index cc4e27ea1..f36712ec2 100644 --- a/app/views/snaptrade_items/_snaptrade_item.html.erb +++ b/app/views/snaptrade_items/_snaptrade_item.html.erb @@ -57,55 +57,57 @@
    -
    - <% if snaptrade_item.requires_update? || !snaptrade_item.user_registered? %> - <%= render DS::Link.new( - text: t(".reconnect"), - icon: "link", - variant: "secondary", - href: connect_snaptrade_item_path(snaptrade_item) - ) %> - <% else %> - <%= icon( - "refresh-cw", - as_button: true, - href: sync_snaptrade_item_path(snaptrade_item), - disabled: snaptrade_item.syncing? - ) %> - <% end %> - - <%= render DS::Menu.new do |menu| %> - <% menu.with_item( - variant: "link", - text: t(".connect_brokerage"), - icon: "plus", - href: connect_snaptrade_item_path(snaptrade_item) - ) %> - <% if unlinked_count > 0 %> - <% menu.with_item( - variant: "link", - text: t(".setup_accounts_menu"), - icon: "settings", - href: setup_accounts_snaptrade_item_path(snaptrade_item), - frame: :modal + <% if Current.user&.admin? %> +
    + <% if snaptrade_item.requires_update? || !snaptrade_item.user_registered? %> + <%= render DS::Link.new( + text: t(".reconnect"), + icon: "link", + variant: "secondary", + href: connect_snaptrade_item_path(snaptrade_item) + ) %> + <% else %> + <%= icon( + "refresh-cw", + as_button: true, + href: sync_snaptrade_item_path(snaptrade_item), + disabled: snaptrade_item.syncing? ) %> <% end %> - <% menu.with_item( - variant: "link", - text: t(".manage_connections"), - icon: "cable", - href: settings_providers_path(manage: "1") - ) %> - <% menu.with_item( - variant: "button", - text: t(".delete"), - icon: "trash-2", - href: snaptrade_item_path(snaptrade_item), - method: :delete, - confirm: CustomConfirm.for_resource_deletion(snaptrade_item.name, high_severity: true) - ) %> - <% end %> -
    + + <%= render DS::Menu.new do |menu| %> + <% menu.with_item( + variant: "link", + text: t(".connect_brokerage"), + icon: "plus", + href: connect_snaptrade_item_path(snaptrade_item) + ) %> + <% if unlinked_count > 0 %> + <% menu.with_item( + variant: "link", + text: t(".setup_accounts_menu"), + icon: "settings", + href: setup_accounts_snaptrade_item_path(snaptrade_item), + frame: :modal + ) %> + <% end %> + <% menu.with_item( + variant: "link", + text: t(".manage_connections"), + icon: "cable", + href: settings_providers_path(manage: "1") + ) %> + <% menu.with_item( + variant: "button", + text: t(".delete"), + icon: "trash-2", + href: snaptrade_item_path(snaptrade_item), + method: :delete, + confirm: CustomConfirm.for_resource_deletion(snaptrade_item.name, high_severity: true) + ) %> + <% end %> +
    + <% end %> <% unless snaptrade_item.scheduled_for_deletion? %> @@ -124,30 +126,32 @@ activities_pending: activities_pending ) %> - <% if unlinked_count > 0 && snaptrade_item.accounts.empty? %> - <%# No accounts imported yet - show prominent setup prompt %> -
    -

    <%= t(".setup_needed") %>

    -

    <%= t(".setup_description") %>

    - <%= render DS::Link.new( - text: t(".setup_action"), - icon: "settings", - variant: "primary", - href: setup_accounts_snaptrade_item_path(snaptrade_item), - frame: :modal - ) %> -
    - <% elsif snaptrade_item.snaptrade_accounts.empty? %> -
    -

    <%= t(".no_accounts_title") %>

    -

    <%= t(".no_accounts_description") %>

    - <%= render DS::Link.new( - text: t(".connect_brokerage"), - icon: "link", - variant: "primary", - href: connect_snaptrade_item_path(snaptrade_item) - ) %> -
    + <% if Current.user&.admin? %> + <% if unlinked_count > 0 && snaptrade_item.accounts.empty? %> + <%# No accounts imported yet - show prominent setup prompt %> +
    +

    <%= t(".setup_needed") %>

    +

    <%= t(".setup_description") %>

    + <%= render DS::Link.new( + text: t(".setup_action"), + icon: "settings", + variant: "primary", + href: setup_accounts_snaptrade_item_path(snaptrade_item), + frame: :modal + ) %> +
    + <% elsif snaptrade_item.snaptrade_accounts.empty? %> +
    +

    <%= t(".no_accounts_title") %>

    +

    <%= t(".no_accounts_description") %>

    + <%= render DS::Link.new( + text: t(".connect_brokerage"), + icon: "link", + variant: "primary", + href: connect_snaptrade_item_path(snaptrade_item) + ) %> +
    + <% end %> <% end %>
    <% end %> diff --git a/app/views/snaptrade_items/setup_accounts.html.erb b/app/views/snaptrade_items/setup_accounts.html.erb index 4ef329562..363626feb 100644 --- a/app/views/snaptrade_items/setup_accounts.html.erb +++ b/app/views/snaptrade_items/setup_accounts.html.erb @@ -34,7 +34,7 @@ <% if @waiting_for_sync %> <%# Syncing state - show spinner with manual refresh option %> -
    +

    <%= t("snaptrade_items.setup_accounts.loading", default: "Fetching accounts from SnapTrade...") %> @@ -60,7 +60,7 @@

    <% elsif @no_accounts_found %> <%# No accounts found after sync completed %> -
    +
    <%= icon "alert-circle", size: "lg", class: "text-warning" %>

    <%= t("snaptrade_items.setup_accounts.no_accounts_title", default: "No Accounts Found") %> diff --git a/app/views/splits/_category_select.html.erb b/app/views/splits/_category_select.html.erb new file mode 100644 index 000000000..001919322 --- /dev/null +++ b/app/views/splits/_category_select.html.erb @@ -0,0 +1,87 @@ +<%# locals: (name:, categories:, selected_id: nil) %> +<% + selected_category = categories.find { |c| c.id == selected_id } + default_color = "#737373" +%> + +

    + + + + +
    diff --git a/app/views/splits/edit.html.erb b/app/views/splits/edit.html.erb new file mode 100644 index 000000000..da8c8f087 --- /dev/null +++ b/app/views/splits/edit.html.erb @@ -0,0 +1,118 @@ +<%= render DS::Dialog.new(variant: "modal") do |dialog| %> + <% dialog.with_header do %> +
    +
    +

    <%= @entry.name %>

    +

    + <%= @entry.date.strftime("%b %d, %Y") %> + <% if (category = @entry.entryable.try(:category)) %> + · + <%= icon category.lucide_icon, size: "xs", color: "current" %> + <%= category.name %> + <% end %> +

    +
    +
    +

    <%= t("splits.new.original_amount") %>

    +

    <%= format_money(-@entry.amount_money) %>

    +
    +
    + <% end %> + + <% dialog.with_body do %> + <%= form_with( + url: transaction_split_path(@entry), + method: :patch, + scope: :split, + class: "space-y-3", + data: { + controller: "split-transaction", + split_transaction_total_value: (-@entry.amount).to_f, + split_transaction_currency_value: @entry.currency, + turbo_frame: :_top + } + ) do %> + + <%# Split rows pre-filled from existing children %> +
    + <% @children.each_with_index do |child, index| %> +
    +
    +
    + + " + class="form-field__input border border-secondary rounded-md px-2.5 py-1.5 w-full text-sm text-primary bg-container" + required + autocomplete="off" + value="<%= child.name %>" + data-split-transaction-target="nameInput"> +
    +
    + + +
    + <%= render "splits/category_select", + name: "split[splits][#{index}][category_id]", + categories: @categories, + selected_id: child.entryable.try(:category_id) %> + +
    +
    + <% end %> +
    + + <%# Add split button %> + + + <%# Remaining balance indicator %> +
    +
    + <%= t("splits.new.remaining") %> + + <%= (-@entry.amount).to_f %> + +
    + +
    + + <%# Actions %> +
    + <%= render DS::Button.new( + text: t("splits.new.cancel"), + variant: "outline", + type: "button", + data: { action: "click->DS--dialog#close" } + ) %> + <%= render DS::Button.new( + text: t("splits.edit.submit"), + variant: "primary", + type: "submit", + data: { split_transaction_target: "submitButton" } + ) %> +
    + <% end %> + <% end %> +<% end %> diff --git a/app/views/splits/new.html.erb b/app/views/splits/new.html.erb new file mode 100644 index 000000000..7b93a71d3 --- /dev/null +++ b/app/views/splits/new.html.erb @@ -0,0 +1,109 @@ +<%= render DS::Dialog.new(variant: "modal") do |dialog| %> + <% dialog.with_header do %> +
    +
    +

    <%= @entry.name %>

    +

    + <%= @entry.date.strftime("%b %d, %Y") %> + <% if (category = @entry.entryable.try(:category)) %> + · + <%= icon category.lucide_icon, size: "xs", color: "current" %> + <%= category.name %> + <% end %> +

    +
    +
    +

    <%= t("splits.new.original_amount") %>

    +

    <%= format_money(-@entry.amount_money) %>

    +
    +
    + <% end %> + + <% dialog.with_body do %> + <%= form_with( + url: transaction_split_path(@entry), + scope: :split, + class: "space-y-3", + data: { + controller: "split-transaction", + split_transaction_total_value: (-@entry.amount).to_f, + split_transaction_currency_value: @entry.currency, + turbo_frame: :_top + } + ) do %> + + <%# Split rows %> +
    +
    +
    +
    + + " + class="form-field__input border border-secondary rounded-md px-2.5 py-1.5 w-full text-sm text-primary bg-container" + required + autocomplete="off" + value="<%= @entry.name %>" + data-split-transaction-target="nameInput"> +
    +
    + + +
    + <%= render "splits/category_select", + name: "split[splits][0][category_id]", + categories: @categories, + selected_id: nil %> + +
    +
    +
    + + <%# Add split button %> + + + <%# Remaining balance indicator %> +
    +
    + <%= t("splits.new.remaining") %> + + <%= (-@entry.amount).to_f %> + +
    +

    + <%= t("splits.new.amounts_must_match") %> +

    +
    + + <%# Actions %> +
    + <%= render DS::Button.new( + text: t("splits.new.cancel"), + variant: "outline", + type: "button", + data: { action: "click->DS--dialog#close" } + ) %> + <%= render DS::Button.new( + text: t("splits.new.submit"), + variant: "primary", + type: "submit", + data: { split_transaction_target: "submitButton" } + ) %> +
    + <% end %> + <% end %> +<% end %> diff --git a/app/views/subscriptions/upgrade.html.erb b/app/views/subscriptions/upgrade.html.erb index beda06883..0c600cf99 100644 --- a/app/views/subscriptions/upgrade.html.erb +++ b/app/views/subscriptions/upgrade.html.erb @@ -25,18 +25,18 @@ <%= image_tag "logo-color.png", class: "w-16 mb-6" %> <% if Current.family.trialing? %> -

    <%= t('subscriptions.upgrade.trialing', days: Current.family.days_left_in_trial) %>

    +

    <%= t("subscriptions.upgrade.trialing", count: Current.family.days_left_in_trial, days: Current.family.days_left_in_trial) %>

    <% else %> -

    <%= t('subscriptions.upgrade.trial_over') %>

    +

    <%= t("subscriptions.upgrade.trial_over") %>

    <% end %>

    - <%= t('subscriptions.upgrade.header.support') %> - <%= t('subscriptions.upgrade.header.sure') %> - <%= t('subscriptions.upgrade.header.today') %> + <%= t("subscriptions.upgrade.header.support") %> + <%= t("subscriptions.upgrade.header.sure") %> + <%= t("subscriptions.upgrade.header.today") %>

    -

    <%= t('subscriptions.upgrade.cta') %>

    +

    <%= t("subscriptions.upgrade.cta") %>

    <%= form_with url: new_subscription_path, method: :get, class: "max-w-xs", data: { turbo: false } do |form| %>
    @@ -46,13 +46,13 @@
    <%= render DS::Button.new( - text: t('subscriptions.upgrade.contribute_and_support_sure'), + text: t("subscriptions.upgrade.contribute_and_support_sure"), variant: "primary", full_width: true ) %>

    - <%= t('subscriptions.upgrade.redirect_to_stripe') %> + <%= t("subscriptions.upgrade.redirect_to_stripe") %>

    <% end %> diff --git a/app/views/trades/_form.html.erb b/app/views/trades/_form.html.erb index 034e890bf..600dde3b9 100644 --- a/app/views/trades/_form.html.erb +++ b/app/views/trades/_form.html.erb @@ -10,11 +10,12 @@
    <%= form.select :type, [ - ["Buy", "buy"], - ["Sell", "sell"], - ["Deposit", "deposit"], - ["Withdrawal", "withdrawal"], - ["Interest", "interest"] + [t(".type_buy"), "buy"], + [t(".type_sell"), "sell"], + [t(".type_deposit"), "deposit"], + [t(".type_withdrawal"), "withdrawal"], + [t(".type_dividend"), "dividend"], + [t(".type_interest"), "interest"] ], { label: t(".type"), selected: type }, { data: { @@ -28,16 +29,25 @@
    <%= form.combobox :ticker, securities_path(country_code: Current.family.country), - name_when_new: "entry[manual_ticker]", + name_when_new: "model[manual_ticker]", label: t(".holding"), placeholder: t(".ticker_placeholder"), required: true %>
    <% else %> - <%= form.text_field :manual_ticker, label: "Ticker symbol", placeholder: "AAPL", required: true %> + <%= form.text_field :manual_ticker, label: t(".holding"), placeholder: t(".ticker_placeholder"), required: true %> <% end %> <% end %> + <% if %w[dividend interest].include?(type) %> + <% account_securities = account ? account.traded_standard_securities : [] %> + <% security_options = account_securities.map { |s| [ s.name.presence || s.ticker, s.exchange_operating_mic.present? ? "#{s.ticker}|#{s.exchange_operating_mic}" : s.ticker ] } %> + <% select_options = { label: type == "dividend" ? t(".holding") : t(".holding_optional") } %> + <% select_options[:include_blank] = true if type == "interest" %> + <% select_options[:required] = true if type == "dividend" %> + <%= form.select :ticker, security_options, select_options %> + <% end %> + <%= form.date_field :date, label: true, value: model.date || Date.current, required: true %> <% unless %w[buy sell].include?(type) %> @@ -45,12 +55,13 @@ <% end %> <% if %w[deposit withdrawal].include?(type) %> - <%= form.collection_select :transfer_account_id, Current.family.accounts.visible.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") } %> + <%= form.collection_select :transfer_account_id, accessible_accounts.visible.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") } %> <% end %> <% if %w[buy sell].include?(type) %> <%= form.number_field :qty, label: t(".qty"), placeholder: "10", min: 0.000000000000000001, step: "any", required: true %> <%= form.money_field :price, label: t(".price"), step: "any", precision: 10, required: true %> + <%= form.money_field :fee, label: t(".fee"), step: "any", min: 0 %> <% end %>
    diff --git a/app/views/trades/_header.html.erb b/app/views/trades/_header.html.erb index 68976a392..94e8a2c5a 100644 --- a/app/views/trades/_header.html.erb +++ b/app/views/trades/_header.html.erb @@ -2,14 +2,20 @@
    <%= tag.header class: "mb-4 space-y-1" do %> + <% label = entry.trade.investment_activity_label %> + <% income_trade = %w[Dividend Interest].include?(label) %> - <%= entry.amount.positive? ? t(".buy") : t(".sell") %> + <% if income_trade %> + <%= t(".#{label.downcase}") %> + <% else %> + <%= entry.amount.positive? ? t(".buy") : t(".sell") %> + <% end %>

    - - <%= format_money entry.amount_money %> + + <%= format_money(income_trade ? entry.amount_money.abs : entry.amount_money) %> @@ -18,7 +24,7 @@

    <% if entry.linked? %> - + "> <%= icon("refresh-ccw", size: "sm") %> <% end %> @@ -31,14 +37,15 @@ <% trade = entry.trade %> + <% unless trade.security.cash? %>
    <%= render DS::Disclosure.new(title: t(".overview"), open: true) do %>
    -
    -
    <%= t(".symbol_label") %>
    -
    <%= trade.security.ticker %>
    -
    +
    +
    <%= t(".symbol_label") %>
    +
    <%= trade.security.ticker %>
    +
    <% if trade.qty.positive? %>
    @@ -48,14 +55,14 @@
    <%= t(".purchase_price_label") %>
    -
    <%= format_money trade.price_money %>
    +
    <%= format_money trade.price_money %>
    <% end %> - <% if trade.security.current_price.present? %> + <% if trade.qty.positive? && trade.security.current_price.present? %>
    <%= t(".current_market_price_label") %>
    -
    <%= format_money trade.security.current_price %>
    +
    <%= format_money trade.security.current_price %>
    <% end %> @@ -71,4 +78,5 @@
    <% end %>
    + <% end %>
    diff --git a/app/views/trades/show.html.erb b/app/views/trades/show.html.erb index 31f49fe27..7e1c222c7 100644 --- a/app/views/trades/show.html.erb +++ b/app/views/trades/show.html.erb @@ -19,30 +19,49 @@ max: Date.current, disabled: @entry.linked?, "data-auto-submit-form-target": "auto" %> -
    - <%= f.select :nature, - [[t(".buy"), "outflow"], [t(".sell"), "inflow"]], - { container_class: "w-1/3", label: t(".type_label"), selected: @entry.amount.positive? ? "outflow" : "inflow" }, - { data: { "auto-submit-form-target": "auto" }, disabled: @entry.linked? } %> - <%= f.fields_for :entryable do |ef| %> - <%= ef.number_field :qty, - label: t(".quantity_label"), - step: "any", - value: trade.qty.abs, - "data-auto-submit-form-target": "auto", - disabled: @entry.linked? %> - <% end %> -
    - <%= f.fields_for :entryable do |ef| %> - <%= ef.money_field :price, - label: t(".cost_per_share_label"), - disable_currency: true, + <% if trade.qty.zero? %> + <%= f.money_field :amount, + label: t(".amount_label"), + value: @entry.amount_money.abs, auto_submit: true, min: 0, step: "any", - precision: 10, disabled: @entry.linked? %> <% end %> + <% unless trade.qty.zero? %> +
    + <%= f.select :nature, + [[t(".buy"), "outflow"], [t(".sell"), "inflow"]], + { container_class: "w-1/3", label: t(".type_label"), selected: @entry.amount.positive? ? "outflow" : "inflow" }, + { data: { "auto-submit-form-target": "auto" }, disabled: @entry.linked? } %> + <%= f.fields_for :entryable do |ef| %> + <%= ef.number_field :qty, + label: t(".quantity_label"), + step: "any", + value: trade.qty.abs, + "data-auto-submit-form-target": "auto", + disabled: @entry.linked? %> + <% end %> +
    + <%= f.fields_for :entryable do |ef| %> + <%= ef.money_field :price, + label: t(".cost_per_share_label"), + disable_currency: true, + auto_submit: true, + min: 0, + step: "any", + disabled: @entry.linked? %> + <% end %> + <%= f.fields_for :entryable do |ef| %> + <%= ef.money_field :fee, + label: t(".fee_label"), + disable_currency: true, + auto_submit: true, + min: 0, + step: "any", + disabled: @entry.linked? %> + <% end %> + <% end %> <% end %>
    <% end %> diff --git a/app/views/transaction_attachments/create.turbo_stream.erb b/app/views/transaction_attachments/create.turbo_stream.erb new file mode 100644 index 000000000..f8a689461 --- /dev/null +++ b/app/views/transaction_attachments/create.turbo_stream.erb @@ -0,0 +1,4 @@ +<%= turbo_stream.replace "transaction_attachments_#{@transaction.id}", partial: "transactions/attachments", locals: { transaction: @transaction, can_upload: @can_upload, can_delete: @can_delete } %> +<% flash_notification_stream_items.each do |item| %> + <%= item %> +<% end %> diff --git a/app/views/transaction_attachments/destroy.turbo_stream.erb b/app/views/transaction_attachments/destroy.turbo_stream.erb new file mode 100644 index 000000000..f8a689461 --- /dev/null +++ b/app/views/transaction_attachments/destroy.turbo_stream.erb @@ -0,0 +1,4 @@ +<%= turbo_stream.replace "transaction_attachments_#{@transaction.id}", partial: "transactions/attachments", locals: { transaction: @transaction, can_upload: @can_upload, can_delete: @can_delete } %> +<% flash_notification_stream_items.each do |item| %> + <%= item %> +<% end %> diff --git a/app/views/transactions/_attachments.html.erb b/app/views/transactions/_attachments.html.erb new file mode 100644 index 000000000..dba4b464f --- /dev/null +++ b/app/views/transactions/_attachments.html.erb @@ -0,0 +1,126 @@ +<%# locals: (transaction:, can_upload: false, can_delete: false) %> + +
    + + <% if can_upload && transaction.attachments.count < Transaction::MAX_ATTACHMENTS_PER_TRANSACTION %> + <%= styled_form_with url: transaction_attachments_path(transaction), + method: :post, + multipart: true, + local: true, + class: "mb-4", + data: { + controller: "attachment-upload", + attachment_upload_max_files_value: Transaction::MAX_ATTACHMENTS_PER_TRANSACTION - transaction.attachments.count, + attachment_upload_max_size_value: Transaction::MAX_ATTACHMENT_SIZE + } do |form| %> +
    +
    +
    + +
    + <%= icon "plus", size: "lg", class: "mb-2 text-secondary" %> +

    <%= t(".browse_to_add") %>

    +
    + + + + <%= form.file_field :attachments, + multiple: true, + accept: Transaction::ALLOWED_CONTENT_TYPES.join(","), + class: "hidden", + data: { + attachment_upload_target: "fileInput", + action: "change->attachment-upload#updateSubmitButton" + } %> +
    + +

    + <%= t(".select_up_to", + count: Transaction::MAX_ATTACHMENTS_PER_TRANSACTION, + size: Transaction::MAX_ATTACHMENT_SIZE / 1.megabyte, + used: transaction.attachments.count) %> +

    +
    + +
    + <%= render DS::Button.new( + text: t(".upload"), + variant: :primary, + size: :sm, + data: { attachment_upload_target: "submitButton" } + ) %> +
    +
    + <% end %> + <% elsif can_upload %> +
    + <%= icon "alert-circle", size: "sm", color: "warning", class: "mt-0.5" %> +
    + <%= t(".max_reached", count: transaction.attachments.count, max: Transaction::MAX_ATTACHMENTS_PER_TRANSACTION) %> +
    +
    + <% end %> + + + <% if transaction.attachments.any? %> +
    +

    <%= t(".files", count: transaction.attachments.count) %>

    +
    + <% transaction.attachments.each do |attachment| %> +
    +
    +
    + <% if attachment.image? %> + <%= icon "image", size: "sm", color: "secondary" %> + <% else %> + <%= icon "file", size: "sm", color: "secondary" %> + <% end %> +
    +
    +

    <%= attachment.filename %>

    +

    <%= number_to_human_size(attachment.byte_size) %>

    +
    +
    + +
    + <%= render DS::Link.new( + href: transaction_attachment_path(transaction, attachment, disposition: :inline), + variant: :outline, + size: :sm, + icon: "eye", + text: "", + target: "_blank" + ) %> + + <%= render DS::Link.new( + href: transaction_attachment_path(transaction, attachment, disposition: :attachment), + variant: :outline, + size: :sm, + icon: "download", + text: "", + data: { turbo: false } + ) %> + + <% if can_delete %> + <%= render DS::Button.new( + href: transaction_attachment_path(transaction, attachment), + method: :delete, + variant: :outline_destructive, + size: :sm, + icon: "trash-2", + confirm: CustomConfirm.for_resource_deletion("attachment") + ) %> + <% end %> +
    +
    + <% end %> +
    +
    + <% else %> +

    <%= t(".no_attachments") %>

    + <% end %> +
    diff --git a/app/views/transactions/_form.html.erb b/app/views/transactions/_form.html.erb index 4375ae0d3..062d87c8b 100644 --- a/app/views/transactions/_form.html.erb +++ b/app/views/transactions/_form.html.erb @@ -1,4 +1,4 @@ -<%# locals: (entry:, income_categories:, expense_categories:) %> +<%# locals: (entry:, categories:) %> <%= styled_form_with model: entry, url: transactions_path, class: "space-y-4" do |f| %> <% if entry.errors.any? %> @@ -18,19 +18,23 @@ <% if @entry.account_id %> <%= f.hidden_field :account_id %> <% else %> - <%= f.collection_select :account_id, Current.family.accounts.manual.active.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") }, required: true, class: "form-field__input text-ellipsis" %> + <%= f.collection_select :account_id, accessible_accounts.manual.active.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account"), selected: Current.user.default_account_for_transactions&.id, variant: :logo }, required: true, class: "form-field__input text-ellipsis" %> <% end %> <%= f.money_field :amount, label: t(".amount"), required: true %> <%= f.fields_for :entryable do |ef| %> - <% categories = params[:nature] == "inflow" ? income_categories : expense_categories %> - <%= ef.collection_select :category_id, categories, :id, :name, { prompt: t(".category_prompt"), label: t(".category") } %> + <%= ef.collection_select :category_id, categories, :id, :name, { prompt: t(".category_prompt"), label: t(".category"), variant: :badge, searchable: true } %> <% end %> <%= f.date_field :date, label: t(".date"), required: true, min: Entry.min_supported_date, max: Date.current, value: Date.current %> <%= render DS::Disclosure.new(title: t(".details")) do %> <%= f.fields_for :entryable do |ef| %> + <%= ef.collection_select :merchant_id, + Current.family.available_merchants_for(Current.user).alphabetically, + :id, :name, + { include_blank: t(".none"), + label: t(".merchant_label") } %> <%= ef.select :tag_ids, Current.family.tags.alphabetically.pluck(:name, :id), { diff --git a/app/views/transactions/_header.html.erb b/app/views/transactions/_header.html.erb index a64898dda..d80c13bed 100644 --- a/app/views/transactions/_header.html.erb +++ b/app/views/transactions/_header.html.erb @@ -2,7 +2,7 @@

    - + <%= format_money -entry.amount_money %> @@ -12,7 +12,7 @@ <%= icon "arrow-left-right", size: "sm", class: "text-secondary" %> <% end %> <% if entry.linked? %> - + " class="text-secondary"> <%= icon("refresh-ccw", size: "sm") %> <% end %> diff --git a/app/views/transactions/_list.html.erb b/app/views/transactions/_list.html.erb index 87eff6c4d..8506bdf85 100644 --- a/app/views/transactions/_list.html.erb +++ b/app/views/transactions/_list.html.erb @@ -39,7 +39,19 @@
    <%= entries_by_date(@transactions.map(&:entry), totals: true) do |entries| %> - <%= render entries %> + <% if Current.user.show_split_grouped? %> + <% group_split_entries(entries, @split_parents).each do |item| %> + <% if item.is_a?(EntriesHelper::SplitGroup) %> + <%= render "entries/split_group", split_group: item %> + <% else %> + <%= render item %> + <% end %> + <% end %> + <% else %> + <% entries.each do |entry| %> + <%= render entry %> + <% end %> + <% end %> <% end %>

    @@ -50,4 +62,4 @@
    <%= render "shared/pagination", pagy: @pagy %>
    -
    \ No newline at end of file +
    diff --git a/app/views/transactions/_selection_bar.html.erb b/app/views/transactions/_selection_bar.html.erb index 062b442ad..bfec49418 100644 --- a/app/views/transactions/_selection_bar.html.erb +++ b/app/views/transactions/_selection_bar.html.erb @@ -7,9 +7,20 @@
    <%= turbo_frame_tag "bulk_transaction_edit_drawer" %> + + <%= link_to new_transaction_path, + class: "p-1.5 group/duplicate hover:bg-inverse flex items-center justify-center rounded-md hidden", + title: t("transactions.selection_bar.duplicate"), + data: { + turbo_frame: "modal", + bulk_select_target: "duplicateLink" + } do %> + <%= icon "copy", class: "group-hover/duplicate:text-inverse" %> + <% end %> + <%= link_to new_transactions_bulk_update_path, class: "p-1.5 group/edit hover:bg-inverse flex items-center justify-center rounded-md", - title: "Edit", + title: t("transactions.selection_bar.edit"), data: { turbo_frame: "bulk_transaction_edit_drawer" } do %> <%= icon "pencil-line", class: "group-hover/edit:text-inverse" %> <% end %> diff --git a/app/views/transactions/_split_parent_row.html.erb b/app/views/transactions/_split_parent_row.html.erb new file mode 100644 index 000000000..70b44ebd2 --- /dev/null +++ b/app/views/transactions/_split_parent_row.html.erb @@ -0,0 +1,69 @@ +<%# locals: (entry:) %> +<% transaction = entry.entryable %> + +
    +
    + <%# Empty space where checkbox would be, for alignment %> + + +
    +
    + + +
    +
    +
    +
    + <%= link_to entry.name, + entry_path(entry), + data: { turbo_frame: "drawer", turbo_prefetch: false }, + class: "hover:underline" %> +
    + +
    + + <%= icon "split", size: "sm", color: "current" %> + <%= t("transactions.split_parent_row.split_label") %> + +
    +
    + +
    + <% if transaction.merchant&.present? %> + + <% end %> + +
    +
    +
    +
    +
    +
    + + + +
    + <%= content_tag :p, format_money(-entry.amount_money) %> +
    +
    diff --git a/app/views/transactions/_summary.html.erb b/app/views/transactions/_summary.html.erb index 818283f2c..b77853cc7 100644 --- a/app/views/transactions/_summary.html.erb +++ b/app/views/transactions/_summary.html.erb @@ -1,19 +1,39 @@ <%# locals: (totals:) %> +<%# Show Inflow/Outflow labels only when the result set contains exclusively transfers + (income and expense are both $0). For mixed filters (e.g. Expense+Transfer), + we keep Income/Expenses labels — transfer amounts aren't included in the summary + bar in that case, though the transaction list still shows both types. %> +<% show_transfers = totals.income_money.zero? && totals.expense_money.zero? && + (totals.transfer_inflow_money.amount > 0 || totals.transfer_outflow_money.amount > 0) %>
    -

    Total transactions

    -

    <%= totals.count.round(0) %>

    +

    <%= t("transactions.summary.total_transactions") %>

    +

    <%= totals.count.round(0) %>

    -

    Income

    -

    - <%= totals.income_money.format %> -

    + <% if show_transfers %> +

    <%= t("transactions.summary.inflow") %>

    +

    + <%= (totals.income_money + totals.transfer_inflow_money).format %> +

    + <% else %> +

    <%= t("transactions.summary.income") %>

    +

    + <%= totals.income_money.format %> +

    + <% end %>
    -

    Expenses

    -

    - <%= totals.expense_money.format %> -

    + <% if show_transfers %> +

    <%= t("transactions.summary.outflow") %>

    +

    + <%= (totals.expense_money + totals.transfer_outflow_money).format %> +

    + <% else %> +

    <%= t("transactions.summary.expenses") %>

    +

    + <%= totals.expense_money.format %> +

    + <% end %>
    diff --git a/app/views/transactions/_transaction.html.erb b/app/views/transactions/_transaction.html.erb index d7993c669..071d2d3e3 100644 --- a/app/views/transactions/_transaction.html.erb +++ b/app/views/transactions/_transaction.html.erb @@ -1,10 +1,10 @@ -<%# locals: (entry:, balance_trend: nil, view_ctx: "global") %> +<%# locals: (entry:, balance_trend: nil, view_ctx: "global", in_split_group: false) %> <% transaction = entry.entryable %> <%= turbo_frame_tag dom_id(entry) do %> <%= turbo_frame_tag dom_id(transaction) do %> -
    "> +
    ">
    <%= check_box_tag dom_id(entry, "selection"), @@ -17,7 +17,16 @@ checkbox_toggle_target: "selectionEntry" } %> -
    +
    + <%= render "transactions/transaction_category", transaction: transaction, variant: "mobile" %> + <% if transaction.merchant&.logo_url.present? %> + <%= image_tag Setting.transform_brand_fetch_url(transaction.merchant.logo_url), + class: "w-5 h-5 rounded-full absolute -bottom-1 -right-1 border border-secondary pointer-events-none", + loading: "lazy" %> + <% end %> +
    + +
    <%= content_tag :div, class: ["flex items-center gap-3 lg:gap-4"] do %> <% end %>
    -
    - <%= render "transactions/transaction_category", transaction: transaction, variant: "mobile" %> - <% if transaction.merchant&.logo_url.present? %> - <%= image_tag Setting.transform_brand_fetch_url(transaction.merchant.logo_url), - class: "w-5 h-5 rounded-full absolute -bottom-1 -right-1 border border-secondary pointer-events-none", - loading: "lazy" %> - <% end %> -
    @@ -101,6 +102,19 @@ <% end %> <% end %> + <%# Split indicator %> + <% if @split_parent_entry_ids ? @split_parent_entry_ids.include?(entry.id) : entry.split_parent? %> + "> + <%= icon "split", size: "sm", color: "current" %> + <%= t("transactions.transaction.split") %> + + <% end %> + <% if entry.split_child? && !in_split_group %> + "> + <%= icon "corner-down-right", size: "sm", color: "current" %> + + <% end %> + <% if transaction.transfer.present? %> <%= render "transactions/transfer_match", transaction: transaction %> <% end %> @@ -145,7 +159,7 @@ <% end %>
    -
    +
    <%# Protection indicator - shows on hover when entry is protected from sync %> <% if entry.protected_from_sync? && !entry.excluded? %> <%= link_to entry_path(entry), @@ -157,7 +171,7 @@ <% end %> <%= content_tag :p, transaction.transfer? && view_ctx == "global" ? "+/- #{format_money(entry.amount_money.abs)}" : format_money(-entry.amount_money), - class: ["text-green-600": entry.amount.negative?] %> + class: ["privacy-sensitive", "text-green-600": entry.amount.negative?] %>
    <% end %> diff --git a/app/views/transactions/_upcoming.html.erb b/app/views/transactions/_upcoming.html.erb index 894883ff7..cf058b240 100644 --- a/app/views/transactions/_upcoming.html.erb +++ b/app/views/transactions/_upcoming.html.erb @@ -26,4 +26,4 @@
    <% else %> <%= render "recurring_transactions/empty" %> -<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/transactions/bulk_updates/new.html.erb b/app/views/transactions/bulk_updates/new.html.erb index a22b30e9f..0aee4d890 100644 --- a/app/views/transactions/bulk_updates/new.html.erb +++ b/app/views/transactions/bulk_updates/new.html.erb @@ -11,7 +11,7 @@ <%= render DS::Disclosure.new(title: "Transactions", open: true) do %>
    <%= form.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: "Select a category", label: "Category", class: "text-subdued" } %> - <%= form.collection_select :merchant_id, Current.family.available_merchants.alphabetically, :id, :name, { prompt: "Select a merchant", label: "Merchant", class: "text-subdued" } %> + <%= form.collection_select :merchant_id, Current.family.available_merchants_for(Current.user).alphabetically, :id, :name, { prompt: "Select a merchant", label: "Merchant", class: "text-subdued" } %> <%= form.select :tag_ids, Current.family.tags.alphabetically.pluck(:name, :id), { include_blank: "None", multiple: true, label: "Tags", include_hidden: false } %> <%= form.text_area :notes, label: "Notes", placeholder: "Enter a note that will be applied to selected transactions", rows: 5 %>
    diff --git a/app/views/transactions/categorizes/_entry_row.html.erb b/app/views/transactions/categorizes/_entry_row.html.erb new file mode 100644 index 000000000..daba95b7f --- /dev/null +++ b/app/views/transactions/categorizes/_entry_row.html.erb @@ -0,0 +1,20 @@ +<%= turbo_frame_tag "categorize_entry_#{entry.id}" do %> + +
    + " + data-action="change->categorize#uncheckRule"> + <%= entry.name %> + <%= l(entry.date, format: :short) %> + "> + <%= format_money(entry.amount_money.abs) %> + + <%= select_tag "category_id", + options_from_collection_for_select(categories, :id, :name), + prompt: t("transactions.categorizes.show.assign_category_prompt"), + aria: { label: t("transactions.categorizes.entry_row.assign_category_select", name: entry.name) }, + class: "w-full text-xs border border-primary rounded-lg px-1.5 py-0.5 bg-container text-secondary", + data: { entry_id: entry.id, action: "change->categorize#assignEntry" } %> +
    +<% end %> diff --git a/app/views/transactions/categorizes/_group_summary.html.erb b/app/views/transactions/categorizes/_group_summary.html.erb new file mode 100644 index 000000000..0477fe7e1 --- /dev/null +++ b/app/views/transactions/categorizes/_group_summary.html.erb @@ -0,0 +1,7 @@ +<%= turbo_frame_tag "categorize_group_summary" do %> +

    + <%= t("transactions.categorizes.show.transaction_count", count: entries.size) %> + · + <%= format_money(entries.sum { |e| e.amount_money.abs }) %> +

    +<% end %> diff --git a/app/views/transactions/categorizes/_group_title.html.erb b/app/views/transactions/categorizes/_group_title.html.erb new file mode 100644 index 000000000..948b504ac --- /dev/null +++ b/app/views/transactions/categorizes/_group_title.html.erb @@ -0,0 +1,10 @@ +<%= turbo_frame_tag "categorize_group_title" do %> +
    +

    <%= display_name %>

    + <% if transaction_type == "income" %> + <%= t("transactions.categorizes.show.type_income") %> + <% elsif transaction_type == "expense" %> + <%= t("transactions.categorizes.show.type_expense") %> + <% end %> +
    +<% end %> diff --git a/app/views/transactions/categorizes/_remaining_count.html.erb b/app/views/transactions/categorizes/_remaining_count.html.erb new file mode 100644 index 000000000..d185e3357 --- /dev/null +++ b/app/views/transactions/categorizes/_remaining_count.html.erb @@ -0,0 +1,5 @@ +<%= turbo_frame_tag "categorize_remaining" do %> + + <%= t("transactions.categorizes.show.remaining", count: total_uncategorized) %> + +<% end %> diff --git a/app/views/transactions/categorizes/_transaction_list.html.erb b/app/views/transactions/categorizes/_transaction_list.html.erb new file mode 100644 index 000000000..d624a0e3c --- /dev/null +++ b/app/views/transactions/categorizes/_transaction_list.html.erb @@ -0,0 +1,20 @@ +<%= turbo_frame_tag "categorize_transaction_list" do %> +
    + <%# Header — same grid template and padding as each row %> +
    +
    +

    <%= t("transactions.categorizes.show.col_transaction") %>

    +

    <%= t("transactions.categorizes.show.col_date") %>

    +

    <%= t("transactions.categorizes.show.col_amount") %>

    +

    <%= t("transactions.categorizes.show.col_category") %>

    +
    + <%# Rows %> +
    +
    + <% entries.each do |entry| %> + <%= render partial: "transactions/categorizes/entry_row", locals: { entry: entry, categories: categories } %> + <% end %> +
    +
    +
    +<% end %> diff --git a/app/views/transactions/categorizes/show.html.erb b/app/views/transactions/categorizes/show.html.erb new file mode 100644 index 000000000..e5cb1f20e --- /dev/null +++ b/app/views/transactions/categorizes/show.html.erb @@ -0,0 +1,145 @@ +<%# Wizard step: categorize one group of uncategorized transactions %> +
    + <%# Top bar: remaining count + skip %> +
    + <%= turbo_frame_tag "categorize_remaining" do %> + + <%= t(".remaining", count: @total_uncategorized) %> + + <% end %> + <%= link_to transactions_categorize_path(position: @position + 1), + class: "flex items-center gap-1.5 text-sm font-medium text-secondary hover:text-primary" do %> + <%= t(".skip") %> + <%= icon("arrow-right", size: "sm") %> + <% end %> +
    + + <%# Group identity — above columns %> +
    +
    + <%= render partial: "transactions/categorizes/group_title", + locals: { display_name: @group.display_name, color: @group.merchant&.color || "#737373", transaction_type: @group.transaction_type } %> +
    + <%= turbo_frame_tag "categorize_group_summary" do %> +

    + <%= t(".transaction_count", count: @group.entries.size) %> + · + <%= format_money(@group.entries.sum { |e| e.amount_money.abs }) %> +

    + <% end %> +
    + + <%# Main form %> + <%= form_with url: transactions_categorize_path, method: :post, id: "categorize-form", class: "w-full" do |form| %> + <%= form.hidden_field :position, value: @position %> + <%= form.hidden_field :transaction_type, value: @group.transaction_type %> + +
    + <%# Left column (60%): rule creation + transaction list %> +
    + <%# Rule creation %> +
    + + +
    1 %>"> + + +

    + <%= t(".rule_description_prefix", type: t(".type_#{@group.transaction_type}").downcase) %> + + "<%= @group.grouping_key %>" + + + + <%= t(".rule_description_suffix") %> +

    +
    +
    + + <%# Transaction list %> +
    +

    <%= t(".transactions_hint") %>

    + <%= render partial: "transactions/categorizes/transaction_list", + locals: { entries: @group.entries, categories: @categories } %> +
    +
    + + <%# Right column (40%): category picker %> +
    +
    +

    <%= t(".assign_category") %>

    + +
    + <%# Search field %> +
    + <%= icon("search", class: "absolute left-3 text-secondary") %> + " + autocomplete="off" + autofocus + class="w-full pl-9 pr-3 py-2 text-sm bg-transparent border-none rounded-lg focus:outline-none focus:ring-0 placeholder:text-secondary" + data-list-filter-target="input" + data-categorize-target="filter" + data-action="input->list-filter#filter"> +
    + + <%# Category pills — submit the form directly %> +
    + + <% @categories.each do |category| %> + + <% end %> +
    +
    +
    +
    +
    + + <% end %> +
    diff --git a/app/views/transactions/convert_to_trade.html.erb b/app/views/transactions/convert_to_trade.html.erb index dc1e5a4ac..631a5c9ab 100644 --- a/app/views/transactions/convert_to_trade.html.erb +++ b/app/views/transactions/convert_to_trade.html.erb @@ -119,7 +119,7 @@
    <%= f.label :price, t(".price_label"), class: "font-medium text-sm text-primary block" %> <%= f.number_field :price, - step: "0.0001", + step: "any", min: "0", placeholder: t(".price_placeholder"), class: "form-field__input border border-secondary rounded-lg px-3 py-2 w-full text-primary bg-container", diff --git a/app/views/transactions/index.html.erb b/app/views/transactions/index.html.erb index 671710c06..c56aec7a4 100644 --- a/app/views/transactions/index.html.erb +++ b/app/views/transactions/index.html.erb @@ -11,6 +11,9 @@ <% menu.with_item(variant: "link", text: "Edit merchants", href: family_merchants_path, icon: "store", data: { turbo_frame: :_top }) %> <% menu.with_item(variant: "link", text: "Edit imports", href: imports_path, icon: "hard-drive-upload", data: { turbo_frame: :_top }) %> <% menu.with_item(variant: "link", text: "Import", href: new_import_path, icon: "download", data: { turbo_frame: "modal", class_name: "md:!hidden" }) %> + <% if @uncategorized_count > 0 %> + <% menu.with_item(variant: "link", text: t(".categorize_button", count: @uncategorized_count), href: transactions_categorize_path, icon: "tag", data: { turbo_frame: :_top }) %> + <% end %> <% end %>
    - <% Current.family.accounts.alphabetically.each do |account| %> + <% Current.user.accessible_accounts.alphabetically.each do |account| %>
    <%= form.check_box :accounts, { diff --git a/app/views/transactions/searches/filters/_merchant_filter.html.erb b/app/views/transactions/searches/filters/_merchant_filter.html.erb index 0c13150b1..bcfd6b789 100644 --- a/app/views/transactions/searches/filters/_merchant_filter.html.erb +++ b/app/views/transactions/searches/filters/_merchant_filter.html.erb @@ -5,7 +5,7 @@ <%= icon("search", class: "absolute inset-y-0 left-2 top-1/2 transform -translate-y-1/2") %>
    - <% Current.family.assigned_merchants.alphabetically.each do |merchant| %> + <% Current.family.assigned_merchants_for(Current.user).alphabetically.each do |merchant| %>
    <%= form.check_box :merchants, { diff --git a/app/views/transactions/searches/filters/_tag_filter.html.erb b/app/views/transactions/searches/filters/_tag_filter.html.erb index 76b198119..8ba4fd0cc 100644 --- a/app/views/transactions/searches/filters/_tag_filter.html.erb +++ b/app/views/transactions/searches/filters/_tag_filter.html.erb @@ -16,15 +16,15 @@ tag.name, nil %> <%= form.label :tags, value: tag.name, class: "text-sm text-primary flex items-center gap-2" do %> - <%= render DS::FilledIcon.new( - variant: :text, - hex_color: tag.color || Tag::UNCATEGORIZED_COLOR, - text: tag.name, - size: "sm", - rounded: true - ) %> - - <%= tag.name %> + <% tag_color = tag.color.presence || Tag::UNCATEGORIZED_COLOR %> + + + <%= tag.name %> + <% end %>
    <% end %> diff --git a/app/views/transactions/show.html.erb b/app/views/transactions/show.html.erb index 3dc25b148..1893b3364 100644 --- a/app/views/transactions/show.html.erb +++ b/app/views/transactions/show.html.erb @@ -47,38 +47,43 @@ <%= render "entries/protection_indicator", entry: @entry, unlock_path: unlock_transaction_path(@entry.transaction) %> <% dialog.with_section(title: t(".overview"), open: true) do %>
    + <% split_locked = @entry.split_child? || @entry.split_parent? %> + <% edit_locked = !can_edit_entry? %> + <% annotate_locked = !can_annotate_entry? %> <%= styled_form_with model: @entry, url: transaction_path(@entry), class: "space-y-2", data: { controller: "auto-submit-form" } do |f| %> <%= f.text_field :name, label: t(".name_label"), + disabled: @entry.split_child? || edit_locked, "data-auto-submit-form-target": "auto" %> <%= f.date_field :date, label: t(".date_label"), max: Date.current, - disabled: @entry.linked?, + disabled: @entry.linked? || split_locked || edit_locked, "data-auto-submit-form-target": "auto" %> <% unless @entry.transaction.transfer? %>
    <%= f.select :nature, [["Expense", "outflow"], ["Income", "inflow"]], { container_class: "w-1/3", label: t(".nature"), selected: @entry.amount.negative? ? "inflow" : "outflow" }, - { data: { "auto-submit-form-target": "auto" }, disabled: @entry.linked? } %> + { data: { "auto-submit-form-target": "auto" }, disabled: @entry.linked? || split_locked || edit_locked } %> <%= f.money_field :amount, label: t(".amount"), container_class: "w-2/3", auto_submit: true, min: 0, value: @entry.amount.abs, - disabled: @entry.linked?, - disable_currency: @entry.linked? %> + disabled: @entry.linked? || split_locked || edit_locked, + disable_currency: @entry.linked? || split_locked || edit_locked %>
    <%= f.fields_for :entryable do |ef| %> <%= ef.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { label: t(".category_label"), - class: "text-subdued", include_blank: t(".uncategorized") }, + class: "text-subdued", include_blank: t(".uncategorized"), + variant: :badge, searchable: true, disabled: @entry.split_child? || annotate_locked }, "data-auto-submit-form-target": "auto" %> <% end %> <% end %> @@ -93,25 +98,26 @@ <% unless @entry.transaction.transfer? %> <%= f.select :account, options_for_select( - Current.family.accounts.alphabetically.pluck(:name, :id), + accessible_accounts.alphabetically.pluck(:name, :id), @entry.account_id ), { label: t(".account_label") }, { disabled: true } %> <%= f.fields_for :entryable do |ef| %> <%= ef.collection_select :merchant_id, - Current.family.available_merchants.alphabetically, + Current.family.available_merchants_for(Current.user).alphabetically, :id, :name, { include_blank: t(".none"), label: t(".merchant_label"), - class: "text-subdued" }, + class: "text-subdued", variant: :logo, searchable: true, disabled: @entry.split_child? || !can_annotate_entry? }, "data-auto-submit-form-target": "auto" %> <%= ef.select :tag_ids, Current.family.tags.alphabetically.pluck(:name, :id), { include_blank: t(".none"), multiple: true, - label: t(".tags_label") + label: t(".tags_label"), + disabled: @entry.split_child? || !can_annotate_entry? }, { "data-controller": "multi-select", "data-auto-submit-form-target": "auto" } %> <% end %> @@ -120,9 +126,15 @@ label: t(".note_label"), placeholder: t(".note_placeholder"), rows: 5, + disabled: @entry.split_child? || !can_annotate_entry?, "data-auto-submit-form-target": "auto" %> <% end %> <% end %> + + <% dialog.with_section(title: t(".attachments")) do %> + <%= render "transactions/attachments", transaction: @entry.transaction, can_upload: can_annotate_entry?, can_delete: can_edit_entry? %> + <% end %> + <% if (details = build_transaction_extra_details(@entry)) %> <% dialog.with_section(title: "Additional details", open: false) do %>
    @@ -169,125 +181,252 @@
    <% end %> <% end %> - <% dialog.with_section(title: t(".settings")) do %> -
    - <%= styled_form_with model: @entry, - url: transaction_path(@entry), - class: "p-3", - data: { controller: "auto-submit-form" } do |f| %> -
    -
    -

    <%= t(".exclude") %>

    -

    <%= t(".exclude_description") %>

    -
    - <%= f.toggle :excluded, { data: { auto_submit_form_target: "auto" } } %> -
    - <% end %> -
    - <% if @entry.account.investment? || @entry.account.crypto? %> -
    - <%= styled_form_with model: @entry, - url: transaction_path(@entry), - class: "p-3", - data: { controller: "auto-submit-form" } do |f| %> - <%= f.fields_for :entryable do |ef| %> -
    -
    -

    <%= t(".activity_type") %>

    -

    <%= t(".activity_type_description") %>

    -
    - <%= ef.select :investment_activity_label, - options_for_select( - [["—", nil]] + Transaction::ACTIVITY_LABELS.map { |l| [t("transactions.activity_labels.#{l.parameterize(separator: '_')}"), l] }, - @entry.entryable.investment_activity_label - ), - { label: false }, - { class: "form-field__input border border-secondary rounded-lg px-3 py-1.5 max-w-40 text-sm", - data: { auto_submit_form_target: "auto" } } %> + <%# Split children list for split parent %> + <% if @entry.split_parent? %> + <% dialog.with_section(title: t("splits.show.title"), open: true) do %> +
    +

    <%= t("splits.show.description") %>

    + <% @entry.child_entries.includes(:entryable).each do |child| %> +
    +
    +

    <%= child.name %>

    +

    <%= child.entryable.try(:category)&.name || t("splits.new.uncategorized") %>

    - <% end %> +

    "> + <%= format_money(-child.amount_money) %> +

    +
    + <% end %> +
    + <%= render DS::Link.new( + text: t("splits.child.edit_split"), + icon: "pencil", + variant: "ghost", + size: :sm, + href: edit_transaction_split_path(@entry), + frame: :modal + ) %> + <%= render DS::Button.new( + text: t("splits.show.unsplit_button"), + icon: "undo-2", + variant: "ghost", + size: :sm, + class: "text-destructive", + href: transaction_split_path(@entry), + method: :delete, + confirm: CustomConfirm.new(title: t("splits.show.unsplit_title"), body: t("splits.show.unsplit_confirm"), btn_text: t("splits.show.unsplit_button"), destructive: true), + frame: "_top" + ) %> +
    +
    + <% end %> + <% end %> + + <%# For split child, show parent info and actions %> + <% if @entry.split_child? %> + <% dialog.with_section(title: t("splits.child.title"), open: true) do %> +
    + <% parent = @entry.parent_entry %> + <% if parent %> +
    +
    +
    +

    <%= parent.name %>

    +

    <%= parent.date.strftime("%b %d, %Y") %>

    +
    +

    + <%= format_money(-parent.amount_money) %> +

    +
    +
    + <%= render DS::Link.new( + text: t("splits.child.edit_split"), + icon: "pencil", + variant: "ghost", + size: :sm, + href: edit_transaction_split_path(parent), + frame: :modal + ) %> + <%= render DS::Button.new( + text: t("splits.child.unsplit"), + icon: "undo-2", + variant: "ghost", + size: :sm, + class: "text-destructive", + href: transaction_split_path(parent), + method: :delete, + confirm: CustomConfirm.new(title: t("splits.show.unsplit_title"), body: t("splits.show.unsplit_confirm"), btn_text: t("splits.show.unsplit_button"), destructive: true), + frame: "_top" + ) %> +
    +
    <% end %>
    <% end %> -
    - <%= styled_form_with model: @entry, - url: transaction_path(@entry), - class: "p-3", - data: { controller: "auto-submit-form" } do |f| %> - <%= f.fields_for :entryable do |ef| %> -
    -
    -

    <%= t(".one_time_title", type: @entry.amount.negative? ? t("transactions.form.income") : t("transactions.form.expense")) %>

    -

    <%= t(".one_time_description") %>

    + <% end %> + + <% if can_edit_entry? %> + <% dialog.with_section(title: t(".settings")) do %> + <% unless @entry.split_parent? || @entry.split_child? %> +
    + <%= styled_form_with model: @entry, + url: transaction_path(@entry), + class: "p-3", + data: { controller: "auto-submit-form" } do |f| %> +
    +
    +

    <%= t(".exclude") %>

    +

    <%= t(".exclude_description") %>

    +
    + <%= f.toggle :excluded, { data: { auto_submit_form_target: "auto" } } %>
    - <%= ef.toggle :kind, { - checked: @entry.transaction.one_time?, - data: { auto_submit_form_target: "auto" } - }, "one_time", "standard" %> -
    - <% end %> - <% end %> -
    -
    -

    Transfer or Debt Payment?

    -

    Transfers and payments are special types of transactions that indicate money movement between 2 accounts.

    + <% end %>
    - <%= render DS::Link.new( - text: "Open matcher", - icon: "arrow-left-right", - variant: "outline", - href: new_transaction_transfer_match_path(@entry), - frame: :modal - ) %> -
    - - <% if @entry.account.investment? && @entry.entryable.is_a?(Transaction) && !@entry.excluded? %> -
    + <% end %> + <% if @entry.account.investment? || @entry.account.crypto? %> +
    + <%= styled_form_with model: @entry, + url: transaction_path(@entry), + class: "p-3", + data: { controller: "auto-submit-form" } do |f| %> + <%= f.fields_for :entryable do |ef| %> +
    +
    +

    <%= t(".activity_type") %>

    +

    <%= t(".activity_type_description") %>

    +
    + <%= ef.select :investment_activity_label, + options_for_select( + [["—", nil]] + Transaction::ACTIVITY_LABELS.map { |l| [t("transactions.activity_labels.#{l.parameterize(separator: '_')}"), l] }, + @entry.entryable.investment_activity_label + ), + { label: false }, + { class: "form-field__input border border-secondary rounded-lg px-3 py-1.5 max-w-40 text-sm", + data: { auto_submit_form_target: "auto" } } %> +
    + <% end %> + <% end %> +
    + <% end %> + <% unless @entry.split_child? %> +
    + <%= styled_form_with model: @entry, + url: transaction_path(@entry), + class: "p-3", + data: { controller: "auto-submit-form" } do |f| %> + <%= f.fields_for :entryable do |ef| %> +
    +
    +

    <%= t(".one_time_title", type: @entry.amount.negative? ? t("transactions.form.income") : t("transactions.form.expense")) %>

    +

    <%= t(".one_time_description") %>

    +
    + <%= ef.toggle :kind, { + checked: @entry.transaction.one_time?, + data: { auto_submit_form_target: "auto" } + }, "one_time", "standard" %> +
    + <% end %> + <% end %> +
    + <% end %> + <%# Split Transaction %> + <% if @entry.transaction.splittable? %> +
    -

    Convert to Security Trade

    -

    Convert this transaction into a security trade (buy/sell) by providing ticker, shares, and price.

    +

    <%= t("splits.show.button_title") %>

    +

    <%= t("splits.show.button_description") %>

    - <%= render DS::Button.new( - text: "Convert", + <%= render DS::Link.new( + text: t("splits.show.button"), + icon: "split", variant: "outline", - icon: "arrow-right-left", - href: convert_to_trade_transaction_path(@entry.transaction), - method: :get, + href: new_transaction_split_path(@entry), frame: :modal ) %>
    <% end %> - -
    -
    -

    <%= t(".mark_recurring_title") %>

    -

    <%= t(".mark_recurring_subtitle") %>

    + <% unless @entry.split_child? %> +
    +
    +

    Transfer or Debt Payment?

    +

    Transfers and payments are special types of transactions that indicate money movement between 2 accounts.

    +
    + <%= render DS::Link.new( + text: "Open matcher", + icon: "arrow-left-right", + variant: "outline", + href: new_transaction_transfer_match_path(@entry), + frame: :modal + ) %>
    - <%= render DS::Button.new( - text: t(".mark_recurring"), - variant: "outline", - icon: "repeat", - href: mark_as_recurring_transaction_path(@entry.transaction), - method: :post, - frame: "_top" - ) %> -
    - -
    -
    -

    <%= t(".delete_title") %>

    -

    <%= t(".delete_subtitle") %>

    + + <% if @entry.entryable.is_a?(Transaction) && @entry.entryable.pending? %> +
    +
    +

    <%= t("transactions.show.pending_duplicate_merger_title") %>

    +

    <%= t("transactions.show.pending_duplicate_merger_description") %>

    +
    + <%= render DS::Link.new( + text: t("transactions.show.pending_duplicate_merger_button"), + icon: "merge", + variant: "outline", + href: new_transaction_pending_duplicate_merges_path(@entry), + frame: :modal + ) %> +
    + <% end %> + + <% if @entry.account.investment? && @entry.entryable.is_a?(Transaction) && !@entry.excluded? %> +
    +
    +

    Convert to Security Trade

    +

    Convert this transaction into a security trade (buy/sell) by providing ticker, shares, and price.

    +
    + <%= render DS::Button.new( + text: "Convert", + variant: "outline", + icon: "arrow-right-left", + href: convert_to_trade_transaction_path(@entry.transaction), + method: :get, + frame: :modal + ) %> +
    + <% end %> + +
    +
    +

    <%= t(".mark_recurring_title") %>

    +

    <%= t(".mark_recurring_subtitle") %>

    +
    + <%= render DS::Button.new( + text: t(".mark_recurring"), + variant: "outline", + icon: "repeat", + href: mark_as_recurring_transaction_path(@entry.transaction), + method: :post, + frame: "_top" + ) %>
    - <%= render DS::Button.new( - text: t(".delete"), - variant: "outline-destructive", - href: entry_path(@entry), - method: :delete, - confirm: CustomConfirm.for_resource_deletion("transaction"), - frame: "_top" - ) %> + <% end %> + <%# Delete Transaction Form - hidden for split children %> + <% unless @entry.split_child? %> +
    +
    +

    <%= t(".delete_title") %>

    +

    <%= t(".delete_subtitle") %>

    +
    + <%= render DS::Button.new( + text: t(".delete"), + variant: "outline-destructive", + href: entry_path(@entry), + method: :delete, + confirm: CustomConfirm.for_resource_deletion("transaction"), + frame: "_top" + ) %> +
    + <% end %>
    -
    + <% end %> <% end %> <% end %> <% end %> diff --git a/app/views/transfers/_form.html.erb b/app/views/transfers/_form.html.erb index aff9d0675..39684a9cd 100644 --- a/app/views/transfers/_form.html.erb +++ b/app/views/transfers/_form.html.erb @@ -14,20 +14,21 @@ <% account_ids << params[:to_account_id] if params[:to_account_id].present? %> <% account_ids << transfer.from_account_id if transfer.respond_to?(:from_account_id) && transfer.from_account_id.present? %> <% account_ids << transfer.to_account_id if transfer.respond_to?(:to_account_id) && transfer.to_account_id.present? %> + <% selected_accounts = @accounts.select { |a| account_ids.include?(a.id) } %> - <% if account_ids.any? && Current.family.accounts.where(id: account_ids).any? { |a| a.investment? || a.crypto? } %> + <% if selected_accounts.any? { |a| a.investment? || a.crypto? } %> <% show_type_tabs = false %> <% end %> <% if show_type_tabs %>
    - <%= render "shared/transaction_type_tabs", active_tab: "transfer" %> + <%= render "shared/transaction_type_tabs", active_tab: "transfer", account_id: @from_account_id %>
    <% end %>
    - <%= f.collection_select :from_account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".from"), selected: @from_account_id }, required: true %> - <%= f.collection_select :to_account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".to") }, required: true %> + <%= f.collection_select :from_account_id, @accounts, :id, :name, { prompt: t(".select_account"), label: t(".from"), selected: @from_account_id, variant: :logo }, required: true %> + <%= f.collection_select :to_account_id, @accounts, :id, :name, { prompt: t(".select_account"), label: t(".to"), variant: :logo }, required: true %> <%= f.number_field :amount, label: t(".amount"), required: true, min: 0, placeholder: "100", step: 0.00000001 %> <%= f.date_field :date, value: transfer.inflow_transaction&.entry&.date || Date.current, label: t(".date"), required: true, max: Date.current %>
    diff --git a/app/views/transfers/new.html.erb b/app/views/transfers/new.html.erb index db8b7d76a..68373153f 100644 --- a/app/views/transfers/new.html.erb +++ b/app/views/transfers/new.html.erb @@ -1,4 +1,4 @@ -<%= render DS::Dialog.new do |dialog| %> +<%= render DS::Dialog.new(scrollable: false) do |dialog| %> <% dialog.with_header(title: t(".title")) %> <% dialog.with_body do %> <%= render "form", transfer: @transfer %> diff --git a/app/views/transfers/show.html.erb b/app/views/transfers/show.html.erb index 5a369a2c6..792899bbb 100644 --- a/app/views/transfers/show.html.erb +++ b/app/views/transfers/show.html.erb @@ -3,7 +3,7 @@

    - + <%= format_money @transfer.amount_abs %> @@ -35,7 +35,7 @@
    Amount
    -
    <%= format_money @transfer.outflow_transaction.entry.amount_money * -1 %>
    +
    <%= format_money @transfer.outflow_transaction.entry.amount_money * -1 %>

    <%= render "shared/ruler", classes: "my-2" %> @@ -53,7 +53,7 @@
    Amount
    -
    +<%= format_money @transfer.inflow_transaction.entry.amount_money * -1 %>
    +
    +<%= format_money @transfer.inflow_transaction.entry.amount_money * -1 %>
    diff --git a/app/views/users/_user_menu.html.erb b/app/views/users/_user_menu.html.erb index b8889c8c6..ab74808e7 100644 --- a/app/views/users/_user_menu.html.erb +++ b/app/views/users/_user_menu.html.erb @@ -54,7 +54,7 @@ <% if self_hosted? && !intro_mode %> <% menu.with_item(variant: "link", text: "Feedback", icon: "megaphone", href: feedback_path) %> <% end %> - <% menu.with_item(variant: "link", text: "Contact", icon: "message-square-more", href: "https://discord.gg/36ZGBsxYEK") %> + <% menu.with_item(variant: "link", text: "Contact", icon: "message-square-more", href: "https://discord.gg/36ZGBsxYEK", target: "_blank", rel: "noopener noreferrer") %> <% menu.with_item(variant: "divider") %> diff --git a/app/views/valuations/_header.html.erb b/app/views/valuations/_header.html.erb index e00959ef4..2db8c8739 100644 --- a/app/views/valuations/_header.html.erb +++ b/app/views/valuations/_header.html.erb @@ -7,7 +7,7 @@

    - + <%= format_money entry.amount_money %> diff --git a/app/views/valuations/_valuation.html.erb b/app/views/valuations/_valuation.html.erb index 201628ff1..46ca62eda 100644 --- a/app/views/valuations/_valuation.html.erb +++ b/app/views/valuations/_valuation.html.erb @@ -26,7 +26,7 @@

    - <%= tag.p format_money(entry.amount_money), class: "font-bold text-sm text-primary" %> + <%= tag.p format_money(entry.amount_money), class: "font-bold text-sm text-primary privacy-sensitive" %>
    <% end %> diff --git a/charts/sure/.gitignore b/charts/sure/.gitignore new file mode 100644 index 000000000..58f68018c --- /dev/null +++ b/charts/sure/.gitignore @@ -0,0 +1,2 @@ +# Vendored subchart tarballs (regenerated by `helm dependency build`) +charts/ diff --git a/charts/sure/CHANGELOG.md b/charts/sure/CHANGELOG.md index f0d636ba5..628fcdce7 100644 --- a/charts/sure/CHANGELOG.md +++ b/charts/sure/CHANGELOG.md @@ -5,22 +5,49 @@ All notable changes to the Sure Helm chart will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -### [0.0.0], [0.6.5] +## [0.6.9-alpha] - 2026-03-24 + +### Changed +- Bumped `pipelock.image.tag` from `1.5.0` to `2.0.0` +- CI: Pipelock GitHub Action updated from `@v1` to `@v2` +- Compose example image changed from pinned `1.5.0` to `latest` with pin comment +- Bumped `pipelock.image.tag` from `0.3.1` to `0.3.2` +- Consolidated `compose.example.pipelock.yml` into `compose.example.ai.yml` — Pipelock now runs alongside Ollama in one compose file with health checks, config volume mount, and MCP env vars (`MCP_API_TOKEN`, `MCP_USER_EMAIL`) +- CI: Pipelock scan `fail-on-findings` changed from `false` to `true`; added `exclude-paths` for locale help text false positives ### Added +- **Pipelock v2.0 features**: + - `pipelock.trustedDomains`: first-class support for allowing internal services whose public DNS resolves to private IPs (prevents SSRF false positives) + - `pipelock.mcpToolPolicy.redirectProfiles`: route matched MCP tool calls to audited handler programs instead of blocking + - Updated `pipelock.example.yaml` with v2.0 feature documentation (trusted domains, redirect profiles, attack simulation, security scoring) + - Updated `extraConfig` comment to mention new v2.0 sections (sandbox, reverse_proxy) +- Pipelock v2.0 highlights available via `extraConfig`: process sandbox (Linux/macOS), generic HTTP reverse proxy, adaptive enforcement exempt domains, kill switch API port isolation +- **Bumped** `pipelock.image.tag` from `0.3.2` to `1.5.0` +- **Pipelock security proxy** (`pipelock.enabled=true`): Separate Deployment + Service that provides two scanning layers + - **Forward proxy** (port 8888): Scans outbound HTTPS from Faraday-based clients (e.g. ruby-openai). Auto-injects `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` env vars into app pods + - **MCP reverse proxy** (port 8889): Scans inbound MCP traffic for DLP, prompt injection, and tool poisoning. Auto-computes upstream URL via `sure.pipelockUpstream` helper + - **WebSocket proxy** configuration support (disabled by default) + - ConfigMap with scanning config (DLP, prompt injection detection, MCP input/tool scanning, response scanning) + - ConfigMap checksum annotation for automatic pod restart on config changes + - Helm helpers: `sure.pipelockImage`, `sure.pipelockUpstream` + - Health and readiness probes on the Pipelock deployment + - `imagePullSecrets` with fallback to app-level secrets + - Boolean safety: uses `hasKey` to prevent Helm's `default` from swallowing explicit `false` + - Configurable ports via `forwardProxy.port` and `mcpProxy.port` (single source of truth across Service, Deployment, and env vars) +- `pipelock.example.yaml` reference config for Docker Compose deployments +- **Pipelock operational hardening**: + - `pipelock.serviceMonitor`: Prometheus Operator ServiceMonitor for /metrics on the proxy port + - `pipelock.ingress`: Ingress template for MCP reverse proxy (external AI assistant access in k8s) + - `pipelock.pdb`: PodDisruptionBudget with minAvailable/maxUnavailable mutual exclusion guard + - `pipelock.topologySpreadConstraints`: Pod spread across nodes + - `pipelock.logging`: Structured logging config (format, output, include_allowed, include_blocked) + - `pipelock.extraConfig`: Escape hatch for additional pipelock.yaml config sections + - `pipelock.requireForExternalAssistant`: Helm guard that fails when externalAssistant is enabled without pipelock + - Component label (`app.kubernetes.io/component: pipelock`) on Service metadata for selector targeting + - NOTES.txt: Pipelock health check commands, MCP access info, security notes, metrics status -- First (nightly/test) releases via - -### [0.6.6] - 2025-12-31 - -### Added - -- First version/release that aligns versions with monorepo -- CNPG: render `Cluster.spec.backup` from `cnpg.cluster.backup`. - - If `backup.method` is omitted and `backup.volumeSnapshot` is present, the chart will infer `method: volumeSnapshot`. - - For snapshot backups, `backup.volumeSnapshot.className` is required (template fails early if missing). - - Example-only keys like `backup.ttl` and `backup.volumeSnapshot.enabled` are stripped to avoid CRD warnings. -- CNPG: render `Cluster.spec.plugins` from `cnpg.cluster.plugins` (enables barman-cloud plugin / WAL archiver configuration). +### Fixed +- Renamed `_asserts.tpl` to `asserts.tpl` — Helm's `_` prefix convention prevented guards from executing ## [0.6.7-alpha] - 2026-01-10 @@ -33,6 +60,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Production-ready HA timeouts: 200ms connect, 1s read/write, 3 reconnection attempts - Backward compatible with existing `REDIS_URL` deployments +### [0.6.6] - 2025-12-31 + +### Added + +- First version/release that aligns versions with monorepo +- CNPG: render `Cluster.spec.backup` from `cnpg.cluster.backup`. + - If `backup.method` is omitted and `backup.volumeSnapshot` is present, the chart will infer `method: volumeSnapshot`. + - For snapshot backups, `backup.volumeSnapshot.className` is required (template fails early if missing). + - Example-only keys like `backup.ttl` and `backup.volumeSnapshot.enabled` are stripped to avoid CRD warnings. +- CNPG: render `Cluster.spec.plugins` from `cnpg.cluster.plugins` (enables barman-cloud plugin / WAL archiver configuration). + +### [0.0.0], [0.6.5] + +### Added + +- First (nightly/test) releases via + ## Notes - Chart version and application version are kept in sync - Requires Kubernetes >= 1.25.0 diff --git a/charts/sure/Chart.lock b/charts/sure/Chart.lock new file mode 100644 index 000000000..5cdb2cfcc --- /dev/null +++ b/charts/sure/Chart.lock @@ -0,0 +1,9 @@ +dependencies: +- name: cloudnative-pg + repository: https://cloudnative-pg.github.io/charts + version: 0.27.1 +- name: redis-operator + repository: https://ot-container-kit.github.io/helm-charts + version: 0.23.0 +digest: sha256:5ffa5c535cb5feea62a29665045a79da8a5d058c3ba11c4db37a4afa97563e3e +generated: "2026-03-02T21:16:32.757224371-05:00" diff --git a/charts/sure/Chart.yaml b/charts/sure/Chart.yaml index 6c2cd02eb..cee4ff9b6 100644 --- a/charts/sure/Chart.yaml +++ b/charts/sure/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: sure description: Official Helm chart for deploying the Sure Rails app (web + Sidekiq) on Kubernetes with optional HA PostgreSQL (CloudNativePG) and Redis. type: application -version: 0.6.8-alpha.14 -appVersion: "0.6.8-alpha.14" +version: 0.7.0-alpha.3 +appVersion: "0.7.0-alpha.3" kubeVersion: ">=1.25.0-0" diff --git a/charts/sure/README.md b/charts/sure/README.md index 6a004d153..28b8e164f 100644 --- a/charts/sure/README.md +++ b/charts/sure/README.md @@ -12,6 +12,7 @@ Official Helm chart for deploying the Sure Rails application on Kubernetes. It s - Optional subcharts - CloudNativePG (operator) + Cluster CR for PostgreSQL with HA support - OT-CONTAINER-KIT redis-operator for Redis HA (replication by default, optional Sentinel) +- Optional Pipelock AI agent security proxy (forward proxy + MCP reverse proxy with DLP, prompt injection, and tool poisoning detection) - Security best practices: runAsNonRoot, readOnlyRootFilesystem, optional existingSecret, no hardcoded secrets - Scalability - Replicas (web/worker), resources, topology spread constraints @@ -637,6 +638,156 @@ hpa: targetCPUUtilizationPercentage: 70 ``` +## Pipelock (AI agent security proxy) + +[Pipelock](https://github.com/luckyPipewrench/pipelock) is an optional security proxy that scans AI agent traffic for secret exfiltration, prompt injection, tool poisoning, and SSRF. It runs as a separate Deployment with two listeners: + +- **Forward proxy** (port 8888): Scans outbound HTTPS from Faraday-based AI clients. Auto-injected via `HTTPS_PROXY` env vars when enabled. +- **MCP reverse proxy** (port 8889): Scans inbound MCP traffic from external AI assistants. + +v2.0 adds enhanced tool poisoning detection (full JSON schema scanning), per-read kill switch preemption on long-lived connections, trusted domain allowlisting, and MCP tool redirect profiles. Process sandboxing and attack simulation are also available via `extraConfig` and CLI. + +### Enabling Pipelock + +```yaml +pipelock: + enabled: true + image: + tag: "2.0.0" + mode: balanced # strict, balanced, or audit +``` + +### Trusted domains + +In Kubernetes, services often have public DNS records that resolve to private IPs. Without `trustedDomains`, the SSRF scanner blocks this legitimate traffic. Add trusted domains to allow them through: + +```yaml +pipelock: + trustedDomains: + - "api.internal.example.com" + - "*.corp.example.com" +``` + +### MCP tool redirect profiles + +Redirect profiles route matched MCP tool calls to an audited handler program instead of blocking. The handler returns a synthetic MCP response, keeping the agent's flow intact while enforcing policy: + +```yaml +pipelock: + mcpToolPolicy: + enabled: true + action: redirect # or use per-rule action overrides + redirectProfiles: + safe-fetch: + exec: ["/pipelock", "internal-redirect", "fetch-proxy"] + reason: "Route fetch calls through audited proxy" +``` + +### Validating your config + +Pipelock v2.0 includes two CLI tools for config validation: + +```bash +# Run 24 synthetic attack scenarios against your config +pipelock simulate --config pipelock.yaml + +# Score your config's security posture (0-100) +pipelock audit score --config pipelock.yaml +``` + +### Exposing MCP to external AI assistants + +When running in Kubernetes, external AI agents need network access to the MCP reverse proxy port. Enable the Pipelock Ingress: + +```yaml +pipelock: + enabled: true + ingress: + enabled: true + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt + hosts: + - host: pipelock.example.com + paths: + - path: / + pathType: Prefix + tls: + - hosts: [pipelock.example.com] + secretName: pipelock-tls +``` + +Security: The Ingress routes to port `mcp` (8889). Ensure `MCP_API_TOKEN` is set so the MCP endpoint requires authentication. The Ingress itself does not add auth. + +### Metrics (Prometheus) + +Pipelock exposes `/metrics` on the forward proxy port. Enable scraping with a ServiceMonitor: + +```yaml +pipelock: + serviceMonitor: + enabled: true + interval: 30s + portName: proxy # matches Service port name for 8888 + additionalLabels: + release: prometheus # match your Prometheus Operator selector +``` + +### PodDisruptionBudget + +Protect Pipelock from node drains: + +```yaml +pipelock: + pdb: + enabled: true + maxUnavailable: 1 # safe for single-replica; use minAvailable when replicas > 1 +``` + +Note: Setting `minAvailable` with `replicas=1` blocks eviction entirely. Use `maxUnavailable` for single-replica deployments. + +### Structured logging + +```yaml +pipelock: + logging: + format: json # json or text + output: stdout + includeAllowed: false + includeBlocked: true +``` + +### Extra config (escape hatch) + +For Pipelock config sections not covered by structured values (session profiling, data budgets, kill switch, sandbox, reverse proxy, adaptive enforcement, etc.), use `extraConfig`: + +```yaml +pipelock: + extraConfig: + session_profiling: + enabled: true + max_sessions: 1000 + adaptive_enforcement: + enabled: true + exempt_domains: + - "*.example.com" + kill_switch: + api_listen: ":9090" # dedicated port for kill switch API +``` + +These are appended verbatim to `pipelock.yaml`. Do not duplicate keys already rendered by the chart. + +### Requiring Pipelock for external assistants + +To enforce that Pipelock is enabled whenever the external AI assistant feature is active: + +```yaml +pipelock: + requireForExternalAssistant: true +``` + +This causes `helm template` / `helm install` to fail if `rails.externalAssistant.enabled=true` and `pipelock.enabled=false`. Note: this only guards the `externalAssistant` path. Direct MCP access via `MCP_API_TOKEN` is configured through env vars and not detectable from Helm values. + ## Security Notes - Never commit secrets in `values.yaml`. Use `rails.existingSecret` or a tool like Sealed Secrets. @@ -660,6 +811,7 @@ See `values.yaml` for the complete configuration surface, including: - `migrations.*`: strategy job or initContainer - `simplefin.encryption.*`: enable + backfill options - `cronjobs.*`: custom CronJobs +- `pipelock.*`: AI agent security proxy (forward proxy, MCP reverse proxy, DLP, injection scanning, trusted domains, tool redirect profiles, logging, serviceMonitor, ingress, PDB, extraConfig) - `service.*`, `ingress.*`, `serviceMonitor.*`, `hpa.*` ## Helm tests diff --git a/charts/sure/templates/NOTES.txt b/charts/sure/templates/NOTES.txt index f4c340b9d..e9e46850f 100644 --- a/charts/sure/templates/NOTES.txt +++ b/charts/sure/templates/NOTES.txt @@ -41,7 +41,40 @@ Troubleshooting - For CloudNativePG, verify the RW service exists and the primary is Ready. - For redis-operator, verify the RedisSentinel CR reports Ready and that the master service resolves. +{{- if .Values.pipelock.enabled }} + +Pipelock (AI agent security proxy) +----------------------------------- +5) Verify pipelock is running: + kubectl rollout status deploy/{{ include "sure.fullname" . }}-pipelock -n {{ .Release.Namespace }} + kubectl logs deploy/{{ include "sure.fullname" . }}-pipelock -n {{ .Release.Namespace }} --tail=20 + +6) MCP access for external AI assistants: +{{- if .Values.pipelock.ingress.enabled }} +{{- range .Values.pipelock.ingress.hosts }} + - Ingress: http{{ if $.Values.pipelock.ingress.tls }}s{{ end }}://{{ .host }} +{{- end }} +{{- else }} + - No Ingress configured. Port-forward for local access: + kubectl port-forward -n {{ .Release.Namespace }} svc/{{ include "sure.fullname" . }}-pipelock 8889:{{ .Values.pipelock.mcpProxy.port | default 8889 }} +{{- end }} + + Security: Enable TLS on the pipelock Ingress and ensure MCP_API_TOKEN is set. + The MCP endpoint requires authentication but the Ingress does not add it. + +7) Metrics: +{{- if .Values.pipelock.serviceMonitor.enabled }} + - ServiceMonitor enabled — Prometheus will scrape /metrics on port {{ .Values.pipelock.serviceMonitor.portName }}. +{{- else }} + - ServiceMonitor not enabled. Metrics are available at http://:{{ .Values.pipelock.forwardProxy.port | default 8888 }}/metrics + Enable with: pipelock.serviceMonitor.enabled=true +{{- end }} +{{- end }} + Security reminder ----------------- - For production, prefer immutable image tags (for example, image.tag=v1.2.3) instead of 'latest'. -- Provide secrets via an existing Kubernetes Secret or a secret manager (External Secrets, Sealed Secrets). \ No newline at end of file +- Provide secrets via an existing Kubernetes Secret or a secret manager (External Secrets, Sealed Secrets). +{{- if .Values.pipelock.enabled }} +- When exposing MCP to external AI assistants, always enable pipelock to scan inbound traffic. +{{- end }} \ No newline at end of file diff --git a/charts/sure/templates/_asserts.tpl b/charts/sure/templates/_asserts.tpl deleted file mode 100644 index de1cf0bbb..000000000 --- a/charts/sure/templates/_asserts.tpl +++ /dev/null @@ -1,7 +0,0 @@ -{{/* -Mutual exclusivity and configuration guards -*/}} - -{{- if and .Values.redisOperator.managed.enabled .Values.redisSimple.enabled -}} -{{- fail "Invalid configuration: Both redisOperator.managed.enabled and redisSimple.enabled are true. Enable only one in-cluster Redis provider." -}} -{{- end -}} diff --git a/charts/sure/templates/_env.tpl b/charts/sure/templates/_env.tpl index ccf0c1b69..e50ab7ce8 100644 --- a/charts/sure/templates/_env.tpl +++ b/charts/sure/templates/_env.tpl @@ -11,6 +11,7 @@ The helper always injects: - optional Active Record Encryption keys (controlled by rails.encryptionEnv.enabled) - optional DATABASE_URL + DB_PASSWORD (includeDatabase=true and helper can compute a DB URL) - optional REDIS_URL + REDIS_PASSWORD (includeRedis=true and helper can compute a Redis URL) +- optional HTTPS_PROXY / HTTP_PROXY / NO_PROXY (pipelock.enabled=true) - rails.settings / rails.extraEnv / rails.extraEnvVars - optional additional per-workload env / envFrom blocks via extraEnv / extraEnvFrom. */}} @@ -77,10 +78,44 @@ The helper always injects: {{- end }} {{- end }} {{- end }} +{{- if and $ctx.Values.pipelock.enabled (ne (toString (dig "forwardProxy" "enabled" true $ctx.Values.pipelock)) "false") }} +{{- $proxyPort := 8888 -}} +{{- if $ctx.Values.pipelock.forwardProxy -}} +{{- $proxyPort = int ($ctx.Values.pipelock.forwardProxy.port | default 8888) -}} +{{- end }} +- name: HTTPS_PROXY + value: {{ printf "http://%s-pipelock.%s.svc.cluster.local:%d" (include "sure.fullname" $ctx) $ctx.Release.Namespace $proxyPort | quote }} +- name: HTTP_PROXY + value: {{ printf "http://%s-pipelock.%s.svc.cluster.local:%d" (include "sure.fullname" $ctx) $ctx.Release.Namespace $proxyPort | quote }} +- name: NO_PROXY + value: "localhost,127.0.0.1,.svc.cluster.local,.cluster.local" +{{- end }} {{- range $k, $v := $ctx.Values.rails.settings }} - name: {{ $k }} value: {{ $v | quote }} {{- end }} +{{- if $ctx.Values.rails.externalAssistant.enabled }} +- name: EXTERNAL_ASSISTANT_URL + value: {{ $ctx.Values.rails.externalAssistant.url | quote }} +{{- if $ctx.Values.rails.externalAssistant.tokenSecretRef }} +- name: EXTERNAL_ASSISTANT_TOKEN + valueFrom: + secretKeyRef: + name: {{ $ctx.Values.rails.externalAssistant.tokenSecretRef.name }} + key: {{ $ctx.Values.rails.externalAssistant.tokenSecretRef.key }} +{{- else }} +- name: EXTERNAL_ASSISTANT_TOKEN + value: {{ $ctx.Values.rails.externalAssistant.token | quote }} +{{- end }} +- name: EXTERNAL_ASSISTANT_AGENT_ID + value: {{ $ctx.Values.rails.externalAssistant.agentId | quote }} +- name: EXTERNAL_ASSISTANT_SESSION_KEY + value: {{ $ctx.Values.rails.externalAssistant.sessionKey | quote }} +{{- if $ctx.Values.rails.externalAssistant.allowedEmails }} +- name: EXTERNAL_ASSISTANT_ALLOWED_EMAILS + value: {{ $ctx.Values.rails.externalAssistant.allowedEmails | quote }} +{{- end }} +{{- end }} {{- range $k, $v := $ctx.Values.rails.extraEnv }} - name: {{ $k }} value: {{ $v | quote }} diff --git a/charts/sure/templates/_helpers.tpl b/charts/sure/templates/_helpers.tpl index 436127959..d36105db9 100644 --- a/charts/sure/templates/_helpers.tpl +++ b/charts/sure/templates/_helpers.tpl @@ -157,3 +157,27 @@ true {{- default "redis-password" .Values.redis.passwordKey -}} {{- end -}} {{- end -}} + +{{/* Pipelock image string */}} +{{- define "sure.pipelockImage" -}} +{{- $repo := "ghcr.io/luckypipewrench/pipelock" -}} +{{- $tag := "latest" -}} +{{- if .Values.pipelock.image -}} +{{- $repo = .Values.pipelock.image.repository | default $repo -}} +{{- $tag = .Values.pipelock.image.tag | default $tag -}} +{{- end -}} +{{- printf "%s:%s" $repo $tag -}} +{{- end -}} + +{{/* Pipelock MCP upstream URL (auto-compute or explicit override) */}} +{{- define "sure.pipelockUpstream" -}} +{{- $upstream := "" -}} +{{- if .Values.pipelock.mcpProxy -}} +{{- $upstream = .Values.pipelock.mcpProxy.upstream | default "" -}} +{{- end -}} +{{- if $upstream -}} +{{- $upstream -}} +{{- else -}} +{{- printf "http://%s:%d/mcp" (include "sure.fullname" .) (int (.Values.service.port | default 80)) -}} +{{- end -}} +{{- end -}} diff --git a/charts/sure/templates/asserts.tpl b/charts/sure/templates/asserts.tpl new file mode 100644 index 000000000..1d481c0e9 --- /dev/null +++ b/charts/sure/templates/asserts.tpl @@ -0,0 +1,23 @@ +{{/* +Mutual exclusivity and configuration guards +*/}} + +{{- if and .Values.redisOperator.managed.enabled .Values.redisSimple.enabled -}} +{{- fail "Invalid configuration: Both redisOperator.managed.enabled and redisSimple.enabled are true. Enable only one in-cluster Redis provider." -}} +{{- end -}} + +{{- $extEnabled := false -}} +{{- if .Values.rails -}}{{- if .Values.rails.externalAssistant -}}{{- if .Values.rails.externalAssistant.enabled -}} +{{- $extEnabled = true -}} +{{- end -}}{{- end -}}{{- end -}} +{{- $plEnabled := false -}} +{{- if .Values.pipelock -}}{{- if .Values.pipelock.enabled -}} +{{- $plEnabled = true -}} +{{- end -}}{{- end -}} +{{- $requirePL := false -}} +{{- if .Values.pipelock -}}{{- if .Values.pipelock.requireForExternalAssistant -}} +{{- $requirePL = true -}} +{{- end -}}{{- end -}} +{{- if and $extEnabled (not $plEnabled) $requirePL -}} +{{- fail "pipelock.requireForExternalAssistant is true but pipelock.enabled is false. Enable pipelock (pipelock.enabled=true) when using rails.externalAssistant, or set pipelock.requireForExternalAssistant=false." -}} +{{- end -}} diff --git a/charts/sure/templates/pipelock-configmap.yaml b/charts/sure/templates/pipelock-configmap.yaml new file mode 100644 index 000000000..b9c8fa5b7 --- /dev/null +++ b/charts/sure/templates/pipelock-configmap.yaml @@ -0,0 +1,151 @@ +{{- if .Values.pipelock.enabled }} +{{- $fwdEnabled := true -}} +{{- $fwdMaxTunnel := 300 -}} +{{- $fwdIdleTimeout := 60 -}} +{{- if .Values.pipelock.forwardProxy -}} +{{- if hasKey .Values.pipelock.forwardProxy "enabled" -}} +{{- $fwdEnabled = .Values.pipelock.forwardProxy.enabled -}} +{{- end -}} +{{- $fwdMaxTunnel = int (.Values.pipelock.forwardProxy.maxTunnelSeconds | default 300) -}} +{{- $fwdIdleTimeout = int (.Values.pipelock.forwardProxy.idleTimeoutSeconds | default 60) -}} +{{- end -}} +{{- $wsEnabled := false -}} +{{- $wsMaxMsg := 1048576 -}} +{{- $wsMaxConns := 128 -}} +{{- $wsScanText := true -}} +{{- $wsAllowBinary := false -}} +{{- $wsForwardCookies := false -}} +{{- $wsMaxConnSec := 3600 -}} +{{- $wsIdleTimeout := 300 -}} +{{- $wsOriginPolicy := "rewrite" -}} +{{- if .Values.pipelock.websocketProxy -}} +{{- if hasKey .Values.pipelock.websocketProxy "enabled" -}} +{{- $wsEnabled = .Values.pipelock.websocketProxy.enabled -}} +{{- end -}} +{{- $wsMaxMsg = int (.Values.pipelock.websocketProxy.maxMessageBytes | default 1048576) -}} +{{- $wsMaxConns = int (.Values.pipelock.websocketProxy.maxConcurrentConnections | default 128) -}} +{{- if hasKey .Values.pipelock.websocketProxy "scanTextFrames" -}} +{{- $wsScanText = .Values.pipelock.websocketProxy.scanTextFrames -}} +{{- end -}} +{{- if hasKey .Values.pipelock.websocketProxy "allowBinaryFrames" -}} +{{- $wsAllowBinary = .Values.pipelock.websocketProxy.allowBinaryFrames -}} +{{- end -}} +{{- if hasKey .Values.pipelock.websocketProxy "forwardCookies" -}} +{{- $wsForwardCookies = .Values.pipelock.websocketProxy.forwardCookies -}} +{{- end -}} +{{- $wsMaxConnSec = int (.Values.pipelock.websocketProxy.maxConnectionSeconds | default 3600) -}} +{{- $wsIdleTimeout = int (.Values.pipelock.websocketProxy.idleTimeoutSeconds | default 300) -}} +{{- $wsOriginPolicy = .Values.pipelock.websocketProxy.originPolicy | default "rewrite" -}} +{{- end -}} +{{- $mcpPolicyEnabled := true -}} +{{- $mcpPolicyAction := "warn" -}} +{{- if .Values.pipelock.mcpToolPolicy -}} +{{- if hasKey .Values.pipelock.mcpToolPolicy "enabled" -}} +{{- $mcpPolicyEnabled = .Values.pipelock.mcpToolPolicy.enabled -}} +{{- end -}} +{{- $mcpPolicyAction = .Values.pipelock.mcpToolPolicy.action | default "warn" -}} +{{- end -}} +{{- $mcpBindingEnabled := true -}} +{{- $mcpBindingAction := "warn" -}} +{{- if .Values.pipelock.mcpSessionBinding -}} +{{- if hasKey .Values.pipelock.mcpSessionBinding "enabled" -}} +{{- $mcpBindingEnabled = .Values.pipelock.mcpSessionBinding.enabled -}} +{{- end -}} +{{- $mcpBindingAction = .Values.pipelock.mcpSessionBinding.unknownToolAction | default "warn" -}} +{{- end -}} +{{- $chainEnabled := true -}} +{{- $chainAction := "warn" -}} +{{- $chainWindow := 20 -}} +{{- $chainGap := 3 -}} +{{- if .Values.pipelock.toolChainDetection -}} +{{- if hasKey .Values.pipelock.toolChainDetection "enabled" -}} +{{- $chainEnabled = .Values.pipelock.toolChainDetection.enabled -}} +{{- end -}} +{{- $chainAction = .Values.pipelock.toolChainDetection.action | default "warn" -}} +{{- $chainWindow = int (.Values.pipelock.toolChainDetection.windowSize | default 20) -}} +{{- $chainGap = int (.Values.pipelock.toolChainDetection.maxGap | default 3) -}} +{{- end -}} +{{- $logFormat := "json" -}} +{{- $logOutput := "stdout" -}} +{{- $logIncludeAllowed := false -}} +{{- $logIncludeBlocked := true -}} +{{- if .Values.pipelock.logging -}} +{{- $logFormat = .Values.pipelock.logging.format | default "json" -}} +{{- $logOutput = .Values.pipelock.logging.output | default "stdout" -}} +{{- if hasKey .Values.pipelock.logging "includeAllowed" -}} +{{- $logIncludeAllowed = .Values.pipelock.logging.includeAllowed -}} +{{- end -}} +{{- if hasKey .Values.pipelock.logging "includeBlocked" -}} +{{- $logIncludeBlocked = .Values.pipelock.logging.includeBlocked -}} +{{- end -}} +{{- end }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "sure.fullname" . }}-pipelock + labels: + {{- include "sure.labels" . | nindent 4 }} +data: + pipelock.yaml: | + version: 1 + mode: {{ .Values.pipelock.mode | default "balanced" }} +{{- if .Values.pipelock.trustedDomains }} + trusted_domains: +{{- range .Values.pipelock.trustedDomains }} + - {{ . | quote }} +{{- end }} +{{- end }} + forward_proxy: + enabled: {{ $fwdEnabled }} + max_tunnel_seconds: {{ $fwdMaxTunnel }} + idle_timeout_seconds: {{ $fwdIdleTimeout }} + websocket_proxy: + enabled: {{ $wsEnabled }} + max_message_bytes: {{ $wsMaxMsg }} + max_concurrent_connections: {{ $wsMaxConns }} + scan_text_frames: {{ $wsScanText }} + allow_binary_frames: {{ $wsAllowBinary }} + forward_cookies: {{ $wsForwardCookies }} + strip_compression: true + max_connection_seconds: {{ $wsMaxConnSec }} + idle_timeout_seconds: {{ $wsIdleTimeout }} + origin_policy: {{ $wsOriginPolicy }} + dlp: + scan_env: true + include_defaults: true + response_scanning: + enabled: true + action: warn + include_defaults: true + mcp_input_scanning: + enabled: true + action: block + on_parse_error: block + mcp_tool_scanning: + enabled: true + action: warn + detect_drift: true + mcp_tool_policy: + enabled: {{ $mcpPolicyEnabled }} + action: {{ $mcpPolicyAction }} +{{- if and .Values.pipelock.mcpToolPolicy .Values.pipelock.mcpToolPolicy.redirectProfiles }} + redirect_profiles: + {{- toYaml .Values.pipelock.mcpToolPolicy.redirectProfiles | nindent 8 }} +{{- end }} + mcp_session_binding: + enabled: {{ $mcpBindingEnabled }} + unknown_tool_action: {{ $mcpBindingAction }} + tool_chain_detection: + enabled: {{ $chainEnabled }} + action: {{ $chainAction }} + window_size: {{ $chainWindow }} + max_gap: {{ $chainGap }} + logging: + format: {{ $logFormat }} + output: {{ $logOutput }} + include_allowed: {{ $logIncludeAllowed }} + include_blocked: {{ $logIncludeBlocked }} +{{- if .Values.pipelock.extraConfig }} + {{- toYaml .Values.pipelock.extraConfig | nindent 4 }} +{{- end }} +{{- end }} diff --git a/charts/sure/templates/pipelock-deployment.yaml b/charts/sure/templates/pipelock-deployment.yaml new file mode 100644 index 000000000..bf57ec8ab --- /dev/null +++ b/charts/sure/templates/pipelock-deployment.yaml @@ -0,0 +1,101 @@ +{{- if .Values.pipelock.enabled }} +{{- $fwdPort := 8888 -}} +{{- $mcpPort := 8889 -}} +{{- $pullPolicy := "IfNotPresent" -}} +{{- if .Values.pipelock.forwardProxy -}} +{{- $fwdPort = int (.Values.pipelock.forwardProxy.port | default 8888) -}} +{{- end -}} +{{- if .Values.pipelock.mcpProxy -}} +{{- $mcpPort = int (.Values.pipelock.mcpProxy.port | default 8889) -}} +{{- end -}} +{{- if .Values.pipelock.image -}} +{{- $pullPolicy = .Values.pipelock.image.pullPolicy | default "IfNotPresent" -}} +{{- end }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "sure.fullname" . }}-pipelock + labels: + {{- include "sure.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.pipelock.replicas | default 1 }} + selector: + matchLabels: + app.kubernetes.io/component: pipelock + {{- include "sure.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + app.kubernetes.io/component: pipelock + {{- include "sure.selectorLabels" . | nindent 8 }} + annotations: + checksum/config: {{ include (print $.Template.BasePath "/pipelock-configmap.yaml") . | sha256sum }} + {{- with .Values.pipelock.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + {{- $plSecrets := coalesce .Values.pipelock.image.imagePullSecrets .Values.image.imagePullSecrets }} + {{- if $plSecrets }} + imagePullSecrets: + {{- toYaml $plSecrets | nindent 8 }} + {{- end }} + volumes: + - name: config + configMap: + name: {{ include "sure.fullname" . }}-pipelock + containers: + - name: pipelock + image: {{ include "sure.pipelockImage" . }} + imagePullPolicy: {{ $pullPolicy }} + args: + - "run" + - "--config" + - "/etc/pipelock/pipelock.yaml" + - "--listen" + - "0.0.0.0:{{ $fwdPort }}" + - "--mcp-listen" + - "0.0.0.0:{{ $mcpPort }}" + - "--mcp-upstream" + - {{ include "sure.pipelockUpstream" . | quote }} + volumeMounts: + - name: config + mountPath: /etc/pipelock + readOnly: true + ports: + - name: proxy + containerPort: {{ $fwdPort }} + protocol: TCP + - name: mcp + containerPort: {{ $mcpPort }} + protocol: TCP + livenessProbe: + httpGet: + path: /health + port: proxy + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health + port: proxy + initialDelaySeconds: 3 + periodSeconds: 5 + timeoutSeconds: 2 + failureThreshold: 3 + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + resources: + {{- toYaml (.Values.pipelock.resources | default dict) | nindent 12 }} + nodeSelector: + {{- toYaml (.Values.pipelock.nodeSelector | default dict) | nindent 8 }} + affinity: + {{- toYaml (.Values.pipelock.affinity | default dict) | nindent 8 }} + tolerations: + {{- toYaml (.Values.pipelock.tolerations | default list) | nindent 8 }} + topologySpreadConstraints: + {{- toYaml (.Values.pipelock.topologySpreadConstraints | default (list)) | nindent 8 }} +{{- end }} diff --git a/charts/sure/templates/pipelock-ingress.yaml b/charts/sure/templates/pipelock-ingress.yaml new file mode 100644 index 000000000..49c3e7ef8 --- /dev/null +++ b/charts/sure/templates/pipelock-ingress.yaml @@ -0,0 +1,42 @@ +{{- if and .Values.pipelock.enabled .Values.pipelock.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "sure.fullname" . }}-pipelock + labels: + {{- include "sure.labels" . | nindent 4 }} + {{- with .Values.pipelock.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.pipelock.ingress.className }} + ingressClassName: {{ .Values.pipelock.ingress.className }} + {{- end }} + {{- if .Values.pipelock.ingress.hosts }} + rules: + {{- range .Values.pipelock.ingress.hosts }} + {{- if not .paths }} + {{- fail "each entry in pipelock.ingress.hosts must include at least one paths item" }} + {{- end }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "sure.fullname" $ }}-pipelock + port: + name: mcp + {{- end }} + {{- end }} + {{- else }} + {{- fail "pipelock.ingress.enabled=true requires at least one entry in pipelock.ingress.hosts" }} + {{- end }} + {{- if .Values.pipelock.ingress.tls }} + tls: + {{- toYaml .Values.pipelock.ingress.tls | nindent 4 }} + {{- end }} +{{- end }} diff --git a/charts/sure/templates/pipelock-pdb.yaml b/charts/sure/templates/pipelock-pdb.yaml new file mode 100644 index 000000000..59f7da34a --- /dev/null +++ b/charts/sure/templates/pipelock-pdb.yaml @@ -0,0 +1,21 @@ +{{- if and .Values.pipelock.enabled .Values.pipelock.pdb.enabled }} +{{- if and .Values.pipelock.pdb.minAvailable .Values.pipelock.pdb.maxUnavailable }} +{{- fail "pipelock.pdb: set either minAvailable or maxUnavailable, not both." -}} +{{- end }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "sure.fullname" . }}-pipelock + labels: + {{- include "sure.labels" . | nindent 4 }} +spec: + {{- if .Values.pipelock.pdb.minAvailable }} + minAvailable: {{ .Values.pipelock.pdb.minAvailable }} + {{- else if .Values.pipelock.pdb.maxUnavailable }} + maxUnavailable: {{ .Values.pipelock.pdb.maxUnavailable }} + {{- end }} + selector: + matchLabels: + app.kubernetes.io/component: pipelock + {{- include "sure.selectorLabels" . | nindent 6 }} +{{- end }} diff --git a/charts/sure/templates/pipelock-service.yaml b/charts/sure/templates/pipelock-service.yaml new file mode 100644 index 000000000..c20cac0a4 --- /dev/null +++ b/charts/sure/templates/pipelock-service.yaml @@ -0,0 +1,31 @@ +{{- if .Values.pipelock.enabled }} +{{- $fwdPort := 8888 -}} +{{- $mcpPort := 8889 -}} +{{- if .Values.pipelock.forwardProxy -}} +{{- $fwdPort = int (.Values.pipelock.forwardProxy.port | default 8888) -}} +{{- end -}} +{{- if .Values.pipelock.mcpProxy -}} +{{- $mcpPort = int (.Values.pipelock.mcpProxy.port | default 8889) -}} +{{- end }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "sure.fullname" . }}-pipelock + labels: + {{- include "sure.labels" . | nindent 4 }} + app.kubernetes.io/component: pipelock +spec: + type: {{ (.Values.pipelock.service).type | default "ClusterIP" }} + selector: + app.kubernetes.io/component: pipelock + {{- include "sure.selectorLabels" . | nindent 4 }} + ports: + - name: proxy + port: {{ $fwdPort }} + targetPort: proxy + protocol: TCP + - name: mcp + port: {{ $mcpPort }} + targetPort: mcp + protocol: TCP +{{- end }} diff --git a/charts/sure/templates/pipelock-servicemonitor.yaml b/charts/sure/templates/pipelock-servicemonitor.yaml new file mode 100644 index 000000000..dfe2d2c54 --- /dev/null +++ b/charts/sure/templates/pipelock-servicemonitor.yaml @@ -0,0 +1,21 @@ +{{- if and .Values.pipelock.enabled .Values.pipelock.serviceMonitor.enabled }} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "sure.fullname" . }}-pipelock + labels: + {{- include "sure.labels" . | nindent 4 }} + {{- with .Values.pipelock.serviceMonitor.additionalLabels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + selector: + matchLabels: + app.kubernetes.io/component: pipelock + {{- include "sure.selectorLabels" . | nindent 6 }} + endpoints: + - interval: {{ .Values.pipelock.serviceMonitor.interval }} + scrapeTimeout: {{ .Values.pipelock.serviceMonitor.scrapeTimeout }} + path: {{ .Values.pipelock.serviceMonitor.path }} + port: {{ .Values.pipelock.serviceMonitor.portName }} +{{- end }} diff --git a/charts/sure/values.yaml b/charts/sure/values.yaml index 3ecd95f94..d2b5da6d0 100644 --- a/charts/sure/values.yaml +++ b/charts/sure/values.yaml @@ -54,6 +54,20 @@ rails: ONBOARDING_STATE: "open" AI_DEBUG_MODE: "false" + # External AI Assistant (optional) + # Delegates chat to a remote AI agent that calls back via MCP. + externalAssistant: + enabled: false + url: "" # e.g., https://your-agent-host/v1/chat + token: "" # Bearer token for the external AI gateway + agentId: "main" # Agent routing identifier + sessionKey: "agent:main:main" # Session key for persistent agent sessions + allowedEmails: "" # Comma-separated emails allowed to use external assistant (empty = all) + # For production, use a Secret reference instead of plaintext: + # tokenSecretRef: + # name: external-assistant-secret + # key: token + # Database: CloudNativePG (operator chart dependency) and a Cluster CR (optional) cloudnative-pg: config: @@ -300,7 +314,7 @@ web: # Probes livenessProbe: httpGet: - path: / + path: /up port: http initialDelaySeconds: 20 periodSeconds: 10 @@ -308,7 +322,7 @@ web: failureThreshold: 6 readinessProbe: httpGet: - path: / + path: /up port: http initialDelaySeconds: 10 periodSeconds: 5 @@ -316,7 +330,7 @@ web: failureThreshold: 6 startupProbe: httpGet: - path: / + path: /up port: http failureThreshold: 30 periodSeconds: 5 @@ -465,3 +479,126 @@ hpa: minReplicas: 2 maxReplicas: 10 targetCPUUtilizationPercentage: 70 + +# Pipelock: AI agent security proxy (optional) +# Provides forward proxy (outbound HTTPS scanning) and MCP reverse proxy +# (inbound MCP traffic scanning for prompt injection, DLP, tool poisoning). +# More info: https://github.com/luckyPipewrench/pipelock +pipelock: + enabled: false + image: + repository: ghcr.io/luckypipewrench/pipelock + tag: "2.0.0" + pullPolicy: IfNotPresent + imagePullSecrets: [] + replicas: 1 + # Pipelock run mode: strict, balanced, audit + mode: balanced + # Trusted domains: allow internal services whose public DNS resolves to private IPs. + # Without this, SSRF scanning blocks legitimate internal-to-internal traffic. + # Example: ["api.internal.example.com", "*.corp.example.com"] + trustedDomains: [] + forwardProxy: + enabled: true + port: 8888 + maxTunnelSeconds: 300 + idleTimeoutSeconds: 60 + mcpProxy: + port: 8889 + # Auto-computed when empty: http://:/mcp + upstream: "" + # WebSocket proxy: bidirectional frame scanning for ws/wss connections. + # Runs on the same listener as the forward proxy at /ws?url=. + websocketProxy: + enabled: false + maxMessageBytes: 1048576 # 1MB per message + maxConcurrentConnections: 128 + scanTextFrames: true # DLP + injection scanning on text frames + allowBinaryFrames: false # block binary frames by default + forwardCookies: false + maxConnectionSeconds: 3600 # 1 hour max connection lifetime + idleTimeoutSeconds: 300 # 5 min idle timeout + originPolicy: rewrite # rewrite, forward, or strip + # MCP tool policy: pre-execution rules for tool calls (shell obfuscation, etc.) + mcpToolPolicy: + enabled: true + action: warn + # Redirect profiles: route matched tool calls to audited handler programs instead + # of blocking. The handler returns a synthetic MCP response. Fail-closed on error. + # Example: + # redirectProfiles: + # safe-fetch: + # exec: ["/pipelock", "internal-redirect", "fetch-proxy"] + # reason: "Route fetch calls through audited proxy" + redirectProfiles: {} + # MCP session binding: pins tool inventory on first tools/list, detects injection + mcpSessionBinding: + enabled: true + unknownToolAction: warn + # Tool call chain detection: detects multi-step attack patterns (recon, exfil, etc.) + toolChainDetection: + enabled: true + action: warn + windowSize: 20 + maxGap: 3 + service: + type: ClusterIP + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + memory: 128Mi + podAnnotations: {} + nodeSelector: {} + tolerations: [] + affinity: {} + topologySpreadConstraints: [] + + # Prometheus Operator ServiceMonitor for /metrics on the proxy port + serviceMonitor: + enabled: false + interval: 30s + scrapeTimeout: 10s + path: /metrics + portName: proxy # matches Service port name "proxy" (8888) + additionalLabels: {} + + # Ingress for MCP reverse proxy (port 8889) — external AI assistants need this in k8s + ingress: + enabled: false + className: "" + annotations: {} + hosts: + - host: pipelock.local + paths: + - path: / + pathType: Prefix + tls: [] + + # PodDisruptionBudget — protects pipelock during node drains. + # WARNING: minAvailable with replicas=1 blocks eviction entirely. + # Use maxUnavailable: 1 for single-replica deployments, or increase replicas. + pdb: + enabled: false + minAvailable: "" # set to 1 when replicas > 1 + maxUnavailable: 1 # safe default: allows 1 pod to be evicted + + # Structured logging for k8s log aggregation + logging: + format: json + output: stdout + includeAllowed: false + includeBlocked: true + + # Escape hatch: ADDITIONAL config sections appended to pipelock.yaml. + # Use for sections not covered by structured values above (session_profiling, + # data_budget, adaptive_enforcement, kill_switch, sandbox, reverse_proxy, etc.) + # Do NOT duplicate keys already rendered above - behavior is parser-dependent. + extraConfig: {} + + # Hard-fail helm template when externalAssistant is enabled without pipelock. + # NOTE: This only guards the rails.externalAssistant path. Direct MCP access + # (/mcp endpoint with MCP_API_TOKEN) is not detectable from Helm values. + # For full coverage, also ensure pipelock is enabled whenever MCP_API_TOKEN is set. + requireForExternalAssistant: false diff --git a/compose.example.ai.yml b/compose.example.ai.yml index e711fc8f4..0ffc93a8b 100644 --- a/compose.example.ai.yml +++ b/compose.example.ai.yml @@ -1,21 +1,42 @@ # =========================================================================== -# Example Docker Compose file with additional Ollama service for LLM tools +# Example Docker Compose file with Ollama (local LLM), OpenClaw (external AI +# assistant), and Pipelock (agent security proxy) # =========================================================================== # # Purpose: # -------- # -# This file is an example Docker Compose configuration for self hosting -# Sure with Ollama on your local machine or on a cloud VPS. +# This file extends the standard Sure setup with optional AI capabilities: # -# The configuration below is a "standard" setup that works out of the box, -# but if you're running this outside of a local network, it is recommended -# to set the environment variables for extra security. +# Pipelock — agent security proxy +# (always runs) +# - Forward proxy (port 8888): scans outbound HTTPS from Faraday-based +# clients (e.g. ruby-openai). NOT covered: SimpleFin, Coinbase, or +# anything using Net::HTTP/HTTParty directly. HTTPS_PROXY is +# cooperative; Docker Compose has no egress network policy. +# - MCP reverse proxy (port 8889): scans inbound AI traffic (DLP, +# prompt injection, tool poisoning, tool call policy). External AI +# clients should connect to Pipelock on port 8889 rather than +# directly to Sure's /mcp endpoint. Note: /mcp is still reachable +# on web port 3000 (auth token required); Pipelock adds scanning +# but Docker Compose cannot enforce network-level routing. +# +# Ollama + Open WebUI — local LLM inference +# (optional, --profile local-ai) +# - Only starts when you run: docker compose --profile local-ai up +# +# Ollama + Open WebUI + OpenClaw — external AI assistant +# (optional, --profile external-assistant) +# - Local LLM inference available via Ollama + Open WebUI +# - Starts an OpenClaw locally for Sure's external assistant mode. +# - Set EXTERNAL_ASSISTANT_URL to http://openclaw:18789/v1/chat/completions +# for internal routing. # # Setup: # ------ # -# To run this, you should read the setup guide: +# 1. Copy pipelock.example.yaml alongside this file (or customize it). +# 2. Read the full setup guide: # # https://github.com/we-promise/sure/blob/main/docs/hosting/docker.md # @@ -24,7 +45,7 @@ # # If you run into problems, you should open a Discussion here: # -# https://github.com/we-promise/sure/discussions/categories/general +# https://github.com/we-promise/sure/discussions/categories/ai-usage # x-db-env: &db_env @@ -41,19 +62,76 @@ x-rails-env: &rails_env DB_HOST: db DB_PORT: 5432 REDIS_URL: redis://redis:6379/1 + # MCP server endpoint — enables /mcp for external AI assistants (e.g. Claude, GPT). + # Set both values to activate. MCP_USER_EMAIL must match an existing user's email. + # External AI clients should connect via Pipelock (port 8889) for scanning. + MCP_API_TOKEN: ${MCP_API_TOKEN:-} + MCP_USER_EMAIL: ${MCP_USER_EMAIL:-} + # Route outbound HTTPS through Pipelock for clients that respect HTTPS_PROXY. + # Covered: OpenAI API (ruby-openai/Faraday). NOT covered: SimpleFin, Coinbase (Net::HTTP). + HTTPS_PROXY: "http://pipelock:8888" + HTTP_PROXY: "http://pipelock:8888" + # Skip proxy for internal Docker network services (including ollama for local LLM calls) + NO_PROXY: "db,redis,pipelock,ollama,openclaw,localhost,127.0.0.1" AI_DEBUG_MODE: "true" # Useful for debugging, set to "false" in production # Ollama using OpenAI API compatible endpoints OPENAI_ACCESS_TOKEN: token-can-be-any-value-for-ollama OPENAI_MODEL: llama3.1:8b # Note: Use tool-enabled model OPENAI_URI_BASE: http://ollama:11434/v1 + # Vector store — pgvector keeps all data local (requires pgvector/pgvector Docker image for db) + VECTOR_STORE_PROVIDER: pgvector + EMBEDDING_MODEL: nomic-embed-text + EMBEDDING_DIMENSIONS: "1024" # NOTE: enabling OpenAI will incur costs when you use AI-related features in the app (chat, rules). Make sure you have set appropriate spend limits on your account before adding this. # OPENAI_ACCESS_TOKEN: ${OPENAI_ACCESS_TOKEN} + # External AI Assistant — delegates chat to a remote AI agent (e.g., OpenClaw). + # The agent calls back to Sure's /mcp endpoint for financial data. + # Set EXTERNAL_ASSISTANT_URL + TOKEN to activate, then either set ASSISTANT_TYPE=external + # here (forces all families) or choose "External" in Settings > Self-Hosting > AI Assistant. + ASSISTANT_TYPE: ${ASSISTANT_TYPE:-} + EXTERNAL_ASSISTANT_URL: ${EXTERNAL_ASSISTANT_URL:-http://openclaw:18789/v1/chat/completions} + EXTERNAL_ASSISTANT_TOKEN: ${EXTERNAL_ASSISTANT_TOKEN:-} + EXTERNAL_ASSISTANT_AGENT_ID: ${EXTERNAL_ASSISTANT_AGENT_ID:-main} + EXTERNAL_ASSISTANT_SESSION_KEY: ${EXTERNAL_ASSISTANT_SESSION_KEY:-agent:main:main} + EXTERNAL_ASSISTANT_ALLOWED_EMAILS: ${EXTERNAL_ASSISTANT_ALLOWED_EMAILS:-} services: + pipelock: + image: ghcr.io/luckypipewrench/pipelock:latest # pin to a specific version (e.g., :2.0.0) for production + container_name: pipelock + hostname: pipelock + restart: unless-stopped + volumes: + - ./pipelock.example.yaml:/etc/pipelock/pipelock.yaml:ro + command: + - "run" + - "--config" + - "/etc/pipelock/pipelock.yaml" + - "--listen" + - "0.0.0.0:8888" + - "--mcp-listen" + - "0.0.0.0:8889" + - "--mcp-upstream" + - "http://web:3000/mcp" + ports: + # MCP reverse proxy — external AI assistants connect here + - "${MCP_PROXY_PORT:-8889}:8889" + # Uncomment to expose forward proxy endpoints (/health, /metrics, /stats): + # - "8888:8888" + healthcheck: + test: ["CMD", "/pipelock", "healthcheck", "--addr", "127.0.0.1:8888"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 30s + networks: + - sure_net + # Note: You still have to download models manually using the ollama CLI or via Open WebUI ollama: profiles: - - ai + - local-ai + - external-assistant volumes: - ollama:/root/.ollama container_name: ollama @@ -64,7 +142,7 @@ services: - "11434:11434" environment: - OLLAMA_KEEP_ALIVE=1h - - OLLAMA_MODELS=deepseek-r1:8b,llama3.1:8b # Pre-load model on startup, you can change this to your preferred model + - OLLAMA_MODELS=deepseek-r1:8b,llama3.1:8b,nomic-embed-text # Pre-load model on startup, you can change this to your preferred model networks: - sure_net # Recommended: Enable GPU support @@ -78,7 +156,8 @@ services: ollama-webui: profiles: - - ai + - local-ai + - external-assistant image: ghcr.io/open-webui/open-webui container_name: ollama-webui volumes: @@ -101,11 +180,66 @@ services: networks: - sure_net + # OpenClaw gateway for Sure's "external assistant" mode. + # Based on OpenClaw Docker install docs: + # https://docs.openclaw.ai/install/docker + openclaw: + profiles: + - external-assistant + image: ${OPENCLAW_IMAGE:-ghcr.io/openclaw/openclaw:latest} + hostname: openclaw + restart: unless-stopped + init: true + environment: + HOME: /home/node + TERM: xterm-256color + TZ: ${OPENCLAW_TZ:-UTC} + OPENCLAW_GATEWAY_TOKEN: ${EXTERNAL_ASSISTANT_TOKEN:-changeme} + OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: ${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-} + command: + [ + "node", + "dist/index.js", + "gateway", + # OpenClaw may exit with "Missing config" on first boot in example setups. + # `--allow-unconfigured` keeps the gateway running until you complete `openclaw setup`. + "pass", + "--allow-unconfigured", + "--bind", + "${OPENCLAW_GATEWAY_BIND:-lan}", + "--port", + "18789", + ] + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:18789/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + ] + interval: 30s + timeout: 5s + retries: 5 + start_period: 20s + ports: + # Expose for local onboarding/debugging. Sure uses the internal service name. + - "${OPENCLAW_GATEWAY_PORT:-18789}:18789" + volumes: + - openclaw-config:/home/node/.openclaw + - openclaw-workspace:/home/node/.openclaw/workspace + networks: + - sure_net + web: image: ghcr.io/we-promise/sure:stable volumes: - app-storage:/rails/storage ports: + # Web UI for browser access. Note: /mcp is also reachable on this port, + # bypassing Pipelock's MCP scanning (auth token is still required). + # For hardened deployments, use `expose: [3000]` instead and front + # the web UI with a separate reverse proxy. - ${PORT:-3000}:3000 restart: unless-stopped environment: @@ -115,6 +249,8 @@ services: condition: service_healthy redis: condition: service_healthy + pipelock: # Remove this block and unset HTTPS_PROXY/HTTP_PROXY to run without Pipelock + condition: service_healthy dns: - 8.8.8.8 - 1.1.1.1 @@ -132,6 +268,8 @@ services: condition: service_healthy redis: condition: service_healthy + pipelock: # Remove this block and unset HTTPS_PROXY/HTTP_PROXY to run without Pipelock + condition: service_healthy dns: - 8.8.8.8 - 1.1.1.1 @@ -141,7 +279,7 @@ services: - sure_net db: - image: postgres:16 + image: pgvector/pgvector:pg16 restart: unless-stopped volumes: - postgres-data:/var/lib/postgresql/data @@ -195,7 +333,10 @@ volumes: redis-data: ollama: ollama-webui: + openclaw-config: + openclaw-workspace: networks: sure_net: driver: bridge + name: sure_net diff --git a/compose.example.pipelock.yml b/compose.example.pipelock.yml deleted file mode 100644 index b70bbb916..000000000 --- a/compose.example.pipelock.yml +++ /dev/null @@ -1,275 +0,0 @@ -# =========================================================================== -# Example Docker Compose file with Pipelock agent security proxy -# =========================================================================== -# -# Purpose: -# -------- -# -# This file adds Pipelock (https://github.com/luckyPipewrench/pipelock) -# as a security proxy for Sure, providing two layers of protection: -# -# 1. Forward proxy (port 8888) — routes outbound HTTPS through Pipelock -# for clients that respect the HTTPS_PROXY environment variable. -# -# 2. MCP reverse proxy (port 8889) — scans inbound MCP traffic from -# external AI assistants bidirectionally (DLP, prompt injection, -# tool poisoning, tool call policy). -# -# Forward proxy coverage: -# ----------------------- -# -# Covered (Faraday-based clients respect HTTPS_PROXY automatically): -# - OpenAI API calls (ruby-openai gem) -# - Market data providers using Faraday -# -# NOT covered (these clients ignore HTTPS_PROXY): -# - SimpleFin (HTTParty / Net::HTTP) -# - Coinbase (HTTParty / Net::HTTP) -# - Any code using Net::HTTP or HTTParty directly -# -# For covered traffic, Pipelock provides: -# - Domain allowlisting (only known-good external APIs can be reached) -# - SSRF protection (blocks connections to private/internal IPs) -# - DLP scanning on connection targets (detects exfiltration patterns) -# - Rate limiting per domain -# - Structured JSON audit logging of all outbound connections -# -# MCP reverse proxy coverage: -# --------------------------- -# -# External AI assistants connect to Pipelock on port 8889 instead of -# directly to Sure's /mcp endpoint. Pipelock scans all traffic: -# -# Request scanning (client → Sure): -# - DLP detection (blocks credential/secret leakage in tool arguments) -# - Prompt injection detection in tool call parameters -# - Tool call policy enforcement (blocks dangerous operations) -# -# Response scanning (Sure → client): -# - Prompt injection detection in tool response content -# - Tool poisoning / drift detection (tool definitions changing) -# -# The MCP endpoint on Sure (port 3000/mcp) should NOT be exposed directly -# to the internet. Route all external MCP traffic through Pipelock. -# -# Limitations: -# ------------ -# -# HTTPS_PROXY is cooperative. Docker Compose has no egress network policy, -# so any code path that doesn't check the env var can connect directly. -# For hard enforcement, deploy with network-level controls that deny all -# egress except through the proxy. Example for Kubernetes: -# -# # NetworkPolicy: deny all egress, allow only proxy + DNS -# egress: -# - to: -# - podSelector: -# matchLabels: -# app: pipelock -# ports: -# - port: 8888 -# - ports: -# - port: 53 -# protocol: UDP -# -# Monitoring: -# ----------- -# -# Pipelock logs every connection and MCP request as structured JSON to stdout. -# View logs with: docker compose logs pipelock -# -# Forward proxy endpoints (port 8888): -# http://localhost:8888/health - liveness check -# http://localhost:8888/metrics - Prometheus metrics -# http://localhost:8888/stats - JSON summary -# -# More info: https://github.com/luckyPipewrench/pipelock -# -# Setup: -# ------ -# -# 1. Copy this file to compose.yml (or use -f flag) -# 2. Set your environment variables (OPENAI_ACCESS_TOKEN, MCP_API_TOKEN, etc.) -# 3. docker compose up -# -# Pipelock runs both proxies in a single container: -# - Port 8888: forward proxy for outbound HTTPS (internal only) -# - Port 8889: MCP reverse proxy for external AI assistants -# -# External AI clients connect to http://:8889 as their MCP endpoint. -# Pipelock scans the traffic and forwards clean requests to Sure's /mcp. -# -# Customization: -# -------------- -# -# Requires Pipelock with MCP HTTP listener support (--mcp-listen flag). -# See: https://github.com/luckyPipewrench/pipelock/releases -# -# Edit the pipelock command to change the mode: -# --mode strict Block unknown domains (recommended for production) -# --mode balanced Warn on unknown domains, block known-bad (default) -# --mode audit Log everything, block nothing (for evaluation) -# -# For a custom config, mount a file and use --config instead of --mode: -# volumes: -# - ./config/pipelock.yml:/etc/pipelock/config.yml:ro -# command: ["run", "--config", "/etc/pipelock/config.yml", -# "--mcp-listen", "0.0.0.0:8889", "--mcp-upstream", "http://web:3000/mcp"] -# - -x-db-env: &db_env - POSTGRES_USER: ${POSTGRES_USER:-sure_user} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-sure_password} - POSTGRES_DB: ${POSTGRES_DB:-sure_production} - -x-rails-env: &rails_env - <<: *db_env - SECRET_KEY_BASE: ${SECRET_KEY_BASE:-a7523c3d0ae56415046ad8abae168d71074a79534a7062258f8d1d51ac2f76d3c3bc86d86b6b0b307df30d9a6a90a2066a3fa9e67c5e6f374dbd7dd4e0778e13} - SELF_HOSTED: "true" - RAILS_FORCE_SSL: "false" - RAILS_ASSUME_SSL: "false" - DB_HOST: db - DB_PORT: 5432 - REDIS_URL: redis://redis:6379/1 - # NOTE: enabling OpenAI will incur costs when you use AI-related features in the app (chat, rules). Make sure you have set appropriate spend limits on your account before adding this. - OPENAI_ACCESS_TOKEN: ${OPENAI_ACCESS_TOKEN} - # MCP server endpoint — enables /mcp for external AI assistants (e.g. Claude, GPT). - # Set both values to activate. MCP_USER_EMAIL must match an existing user's email. - # External AI clients connect via Pipelock (port 8889), not directly to /mcp. - MCP_API_TOKEN: ${MCP_API_TOKEN:-} - MCP_USER_EMAIL: ${MCP_USER_EMAIL:-} - # Route outbound HTTPS through Pipelock for clients that respect HTTPS_PROXY. - # See "Forward proxy coverage" section above for which clients are covered. - HTTPS_PROXY: "http://pipelock:8888" - HTTP_PROXY: "http://pipelock:8888" - # Skip proxy for internal Docker network services - NO_PROXY: "db,redis,pipelock,localhost,127.0.0.1" - -services: - pipelock: - image: ghcr.io/luckypipewrench/pipelock:latest - container_name: pipelock - hostname: pipelock - restart: unless-stopped - command: - - "run" - - "--listen" - - "0.0.0.0:8888" - - "--mode" - - "balanced" - - "--mcp-listen" - - "0.0.0.0:8889" - - "--mcp-upstream" - - "http://web:3000/mcp" - ports: - # MCP reverse proxy — external AI assistants connect here - - "${MCP_PROXY_PORT:-8889}:8889" - # Uncomment to expose forward proxy endpoints (/health, /metrics, /stats): - # - "8888:8888" - healthcheck: - test: ["CMD", "/pipelock", "healthcheck", "--addr", "127.0.0.1:8888"] - interval: 10s - timeout: 5s - retries: 3 - start_period: 30s - networks: - - sure_net - - web: - image: ghcr.io/we-promise/sure:stable - volumes: - - app-storage:/rails/storage - ports: - # Web UI for browser access. Note: /mcp is also reachable on this port, - # bypassing Pipelock's MCP scanning (auth token is still required). - # For hardened deployments, use `expose: [3000]` instead and front - # the web UI with a separate reverse proxy. - - ${PORT:-3000}:3000 - restart: unless-stopped - environment: - <<: *rails_env - depends_on: - db: - condition: service_healthy - redis: - condition: service_healthy - pipelock: - condition: service_healthy - networks: - - sure_net - - worker: - image: ghcr.io/we-promise/sure:stable - command: bundle exec sidekiq - volumes: - - app-storage:/rails/storage - restart: unless-stopped - depends_on: - db: - condition: service_healthy - redis: - condition: service_healthy - pipelock: - condition: service_healthy - environment: - <<: *rails_env - networks: - - sure_net - - db: - image: postgres:16 - restart: unless-stopped - volumes: - - postgres-data:/var/lib/postgresql/data - environment: - <<: *db_env - healthcheck: - test: [ "CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" ] - interval: 5s - timeout: 5s - retries: 5 - networks: - - sure_net - - backup: - profiles: - - backup - image: prodrigestivill/postgres-backup-local - restart: unless-stopped - volumes: - - /opt/sure-data/backups:/backups # Change this path to your desired backup location on the host machine - environment: - - POSTGRES_HOST=db - - POSTGRES_DB=${POSTGRES_DB:-sure_production} - - POSTGRES_USER=${POSTGRES_USER:-sure_user} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-sure_password} - - SCHEDULE=@daily # Runs once a day at midnight - - BACKUP_KEEP_DAYS=7 # Keeps the last 7 days of backups - - BACKUP_KEEP_WEEKS=4 # Keeps 4 weekly backups - - BACKUP_KEEP_MONTHS=6 # Keeps 6 monthly backups - depends_on: - - db - networks: - - sure_net - - redis: - image: redis:latest - restart: unless-stopped - volumes: - - redis-data:/data - healthcheck: - test: [ "CMD", "redis-cli", "ping" ] - interval: 5s - timeout: 5s - retries: 5 - networks: - - sure_net - -volumes: - app-storage: - postgres-data: - redis-data: - -networks: - sure_net: - driver: bridge diff --git a/config/application.rb b/config/application.rb index 3a63072b2..1269f1aad 100644 --- a/config/application.rb +++ b/config/application.rb @@ -39,6 +39,9 @@ module Sure theme: [ "light", "dark" ] # available in view as params[:theme] } + # Enable Skylight instrumentation for ActiveJob (background workers) + config.skylight.probes << "active_job" if defined?(Skylight) + # Enable Rack::Attack middleware for API rate limiting config.middleware.use Rack::Attack diff --git a/config/environments/production.rb b/config/environments/production.rb index 347a18617..fc7120a0a 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -78,11 +78,13 @@ Rails.application.configure do config.action_mailer.default_url_options = { host: ENV["APP_DOMAIN"] } config.action_mailer.delivery_method = :smtp config.action_mailer.smtp_settings = { - address: ENV["SMTP_ADDRESS"], - port: ENV["SMTP_PORT"], - user_name: ENV["SMTP_USERNAME"], - password: ENV["SMTP_PASSWORD"], - tls: ENV["SMTP_TLS_ENABLED"] == "true" + address: ENV["SMTP_ADDRESS"], + port: ENV["SMTP_PORT"], + user_name: ENV["SMTP_USERNAME"], + password: ENV["SMTP_PASSWORD"], + tls: ENV["SMTP_TLS_ENABLED"] == "true", + openssl_verify_mode: ENV["SMTP_TLS_SKIP_VERIFY"] == "true" ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER, + ca_file: ENV["SSL_CA_FILE"] } # Ignore bad email addresses and do not raise email delivery errors. diff --git a/config/initializers/active_storage_authorization.rb b/config/initializers/active_storage_authorization.rb new file mode 100644 index 000000000..7766dc3c3 --- /dev/null +++ b/config/initializers/active_storage_authorization.rb @@ -0,0 +1,45 @@ +# Override Active Storage blob serving to enforce authorization +Rails.application.config.to_prepare do + module ActiveStorageAttachmentAuthorization + extend ActiveSupport::Concern + + included do + include Authentication + before_action :authorize_transaction_attachment, if: :transaction_attachment? + end + + private + + def authorize_transaction_attachment + attachment = ActiveStorage::Attachment.find_by(blob: authorized_blob) + return unless attachment&.record_type == "Transaction" + + transaction = attachment.record + + # Check if current user has access to this transaction's family + unless Current.family == transaction.entry.account.family + raise ActiveRecord::RecordNotFound + end + end + + def transaction_attachment? + return false unless authorized_blob + + attachment = ActiveStorage::Attachment.find_by(blob: authorized_blob) + attachment&.record_type == "Transaction" + end + + def authorized_blob + @blob || @representation&.blob + end + end + + [ + ActiveStorage::Blobs::RedirectController, + ActiveStorage::Blobs::ProxyController, + ActiveStorage::Representations::RedirectController, + ActiveStorage::Representations::ProxyController + ].each do |controller| + controller.include ActiveStorageAttachmentAuthorization + end +end diff --git a/config/initializers/rswag.rb b/config/initializers/rswag.rb new file mode 100644 index 000000000..5bef48784 --- /dev/null +++ b/config/initializers/rswag.rb @@ -0,0 +1,11 @@ +if defined?(Rswag::Ui) && Rails.env.development? + Rswag::Ui.configure do |c| + c.openapi_endpoint "/api-docs/openapi.yaml", "Sure API V1" + end +end + +if defined?(Rswag::Api) && Rails.env.development? + Rswag::Api.configure do |c| + c.openapi_root = Rails.root.join("docs", "api").to_s + end +end diff --git a/config/initializers/version.rb b/config/initializers/version.rb index 60ce0779c..fa7f79b90 100644 --- a/config/initializers/version.rb +++ b/config/initializers/version.rb @@ -16,7 +16,7 @@ module Sure private def semver - "0.6.8-alpha.14" + "0.7.0-alpha.3" end end end diff --git a/config/locales/breadcrumbs/en.yml b/config/locales/breadcrumbs/en.yml index 9d81ba325..1c840acdc 100644 --- a/config/locales/breadcrumbs/en.yml +++ b/config/locales/breadcrumbs/en.yml @@ -1,6 +1,8 @@ --- en: breadcrumbs: + categorize: Categorize exports: Exports home: Home imports: Imports + transactions: Transactions diff --git a/config/locales/breadcrumbs/pl.yml b/config/locales/breadcrumbs/pl.yml new file mode 100644 index 000000000..5d4755451 --- /dev/null +++ b/config/locales/breadcrumbs/pl.yml @@ -0,0 +1,6 @@ +--- +pl: + breadcrumbs: + exports: Eksporty + home: Strona główna + imports: Importy diff --git a/config/locales/defaults/de.yml b/config/locales/defaults/de.yml index 1d0f1af64..27bdd0dd2 100644 --- a/config/locales/defaults/de.yml +++ b/config/locales/defaults/de.yml @@ -3,6 +3,8 @@ de: defaults: brand_name: "%{brand_name}" product_name: "%{product_name}" + global: + expand: "Aufklappen" activerecord: errors: messages: diff --git a/config/locales/defaults/en.yml b/config/locales/defaults/en.yml index f2caaa233..bf860dde4 100644 --- a/config/locales/defaults/en.yml +++ b/config/locales/defaults/en.yml @@ -153,6 +153,8 @@ en: helpers: select: prompt: Please select + search_placeholder: "Search" + default_label: "Select..." submit: create: Create %{model} submit: Save %{model} diff --git a/config/locales/defaults/es.yml b/config/locales/defaults/es.yml index 96757c381..82b73c583 100644 --- a/config/locales/defaults/es.yml +++ b/config/locales/defaults/es.yml @@ -1,5 +1,10 @@ --- es: + defaults: + brand_name: "%{brand_name}" + product_name: "%{product_name}" + global: + expand: "Expandir" activerecord: errors: messages: diff --git a/config/locales/doorkeeper.pl.yml b/config/locales/doorkeeper.pl.yml new file mode 100644 index 000000000..a561c3b7d --- /dev/null +++ b/config/locales/doorkeeper.pl.yml @@ -0,0 +1,153 @@ +pl: + activerecord: + attributes: + doorkeeper/application: + name: "Nazwa" + redirect_uri: "URI przekierowania" + errors: + models: + doorkeeper/application: + attributes: + redirect_uri: + fragment_present: "nie może zawierać fragmentu." + invalid_uri: "musi być poprawnym URI." + unspecified_scheme: "musi określać schemat." + relative_uri: "musi być bezwzględnym URI." + secured_uri: "musi być URI HTTPS/SSL." + forbidden_uri: "jest zabronione przez serwer." + scopes: + not_match_configured: "nie odpowiada zakresom skonfigurowanym na serwerze." + + doorkeeper: + applications: + confirmations: + destroy: "Czy na pewno?" + buttons: + edit: "Edytuj" + destroy: "Usuń" + submit: "Zapisz" + cancel: "Anuluj" + authorize: "Autoryzuj" + form: + error: "Ups! Sprawdź formularz pod kątem możliwych błędów" + help: + confidential: "Aplikacja będzie używana tam, gdzie sekret klienta może pozostać poufny. Natywne aplikacje mobilne i aplikacje Single Page App są uznawane za niepoufne." + redirect_uri: "Użyj jednej linii dla każdego URI" + blank_redirect_uri: "Pozostaw puste, jeśli skonfigurowano dostawcę do użycia Client Credentials, Resource Owner Password Credentials lub innego typu grant, który nie wymaga URI przekierowania." + scopes: "Oddzielaj zakresy spacjami. Pozostaw puste, aby użyć domyślnych zakresów." + edit: + title: "Edytuj aplikację" + index: + title: "Twoje aplikacje" + new: "Nowa aplikacja" + name: "Nazwa" + callback_url: "URL callback" + confidential: "Poufna?" + actions: "Akcje" + confidentiality: + "yes": "Tak" + "no": "Nie" + new: + title: "Nowa aplikacja" + show: + title: "Aplikacja: %{name}" + application_id: "UID" + secret: "Sekret" + secret_hashed: "Zahaszowany sekret" + scopes: "Zakresy" + confidential: "Poufna" + callback_urls: "URL-e callback" + actions: "Akcje" + not_defined: "Nie zdefiniowano" + + authorizations: + buttons: + authorize: "Autoryzuj" + deny: "Odmów" + error: + title: "Wystąpił błąd" + new: + title: "Wymagana autoryzacja" + prompt: "Zezwolić aplikacji %{client_name} na użycie Twojego konta?" + able_to: "Ta aplikacja będzie mogła" + show: + title: "Kod autoryzacyjny" + form_post: + title: "Wyślij ten formularz" + + authorized_applications: + confirmations: + revoke: "Czy na pewno?" + buttons: + revoke: "Cofnij" + index: + title: "Twoje autoryzowane aplikacje" + application: "Aplikacja" + created_at: "Utworzono" + date_format: "%Y-%m-%d %H:%M:%S" + + pre_authorization: + status: "Wstępna autoryzacja" + + errors: + messages: + invalid_request: + unknown: "Żądanie nie zawiera wymaganego parametru, zawiera nieobsługiwaną wartość parametru lub jest nieprawidłowo sformułowane." + missing_param: "Brakuje wymaganego parametru: %{value}." + request_not_authorized: "Żądanie wymaga autoryzacji. Wymagany parametr autoryzacji jest brakujący lub nieprawidłowy." + invalid_code_challenge: "Code challenge jest wymagany." + invalid_redirect_uri: "Żądany URI przekierowania jest nieprawidłowy lub nie zgadza się z URI przekierowania klienta." + unauthorized_client: "Klient nie ma uprawnień do wykonania tego żądania tą metodą." + access_denied: "Właściciel zasobu lub serwer autoryzacji odrzucił żądanie." + invalid_scope: "Żądany zakres jest nieprawidłowy, nieznany lub błędnie sformułowany." + invalid_code_challenge_method: + zero: "Serwer autoryzacji nie obsługuje PKCE, ponieważ brak akceptowanych wartości code_challenge_method." + one: "code_challenge_method musi mieć wartość %{challenge_methods}." + few: "code_challenge_method musi mieć jedną z wartości: %{challenge_methods}." + many: "code_challenge_method musi mieć jedną z wartości: %{challenge_methods}." + other: "code_challenge_method musi mieć jedną z wartości: %{challenge_methods}." + server_error: "Serwer autoryzacji napotkał nieoczekiwany warunek, który uniemożliwił realizację żądania." + temporarily_unavailable: "Serwer autoryzacji jest chwilowo niedostępny z powodu przeciążenia lub prac serwisowych." + + credential_flow_not_configured: "Przepływ Resource Owner Password Credentials zakończył się niepowodzeniem, ponieważ Doorkeeper.configure.resource_owner_from_credentials nie jest skonfigurowany." + resource_owner_authenticator_not_configured: "Wyszukiwanie właściciela zasobu zakończyło się niepowodzeniem, ponieważ Doorkeeper.configure.resource_owner_authenticator nie jest skonfigurowany." + admin_authenticator_not_configured: "Dostęp do panelu administratora jest zabroniony, ponieważ Doorkeeper.configure.admin_authenticator nie jest skonfigurowany." + + unsupported_response_type: "Serwer autoryzacji nie obsługuje tego typu odpowiedzi." + unsupported_response_mode: "Serwer autoryzacji nie obsługuje tego trybu odpowiedzi." + + invalid_client: "Uwierzytelnienie klienta nie powiodło się z powodu nieznanego klienta, braku uwierzytelnienia klienta lub nieobsługiwanej metody uwierzytelnienia." + invalid_grant: "Podany grant autoryzacyjny jest nieprawidłowy, wygasł, został cofnięty, nie odpowiada URI przekierowania użytemu w żądaniu autoryzacji lub został wydany innemu klientowi." + unsupported_grant_type: "Typ grantu autoryzacyjnego nie jest obsługiwany przez serwer autoryzacji." + + invalid_token: + revoked: "Token dostępu został cofnięty" + expired: "Token dostępu wygasł" + unknown: "Token dostępu jest nieprawidłowy" + revoke: + unauthorized: "Nie masz uprawnień do cofnięcia tego tokenu" + + forbidden_token: + missing_scope: "Dostęp do tego zasobu wymaga zakresu \"%{oauth_scopes}\"." + + flash: + applications: + create: + notice: "Aplikacja została utworzona." + destroy: + notice: "Aplikacja została usunięta." + update: + notice: "Aplikacja została zaktualizowana." + authorized_applications: + destroy: + notice: "Autoryzacja aplikacji została cofnięta." + + layouts: + admin: + title: "Doorkeeper" + nav: + oauth2_provider: "Dostawca OAuth2" + applications: "Aplikacje" + home: "Strona główna" + application: + title: "Wymagana autoryzacja OAuth" \ No newline at end of file diff --git a/config/locales/mailers/invitation_mailer/pl.yml b/config/locales/mailers/invitation_mailer/pl.yml new file mode 100644 index 000000000..ff833bd77 --- /dev/null +++ b/config/locales/mailers/invitation_mailer/pl.yml @@ -0,0 +1,5 @@ +--- +pl: + invitation_mailer: + invite_email: + subject: "%{inviter} zaprosił(a) Cię do dołączenia do swojego gospodarstwa domowego w %{product_name}!" diff --git a/config/locales/mailers/pdf_import_mailer/de.yml b/config/locales/mailers/pdf_import_mailer/de.yml new file mode 100644 index 000000000..072a80c2e --- /dev/null +++ b/config/locales/mailers/pdf_import_mailer/de.yml @@ -0,0 +1,5 @@ +--- +de: + pdf_import_mailer: + next_steps: + subject: "Ihr PDF-Dokument wurde analysiert - %{product_name}" diff --git a/config/locales/mailers/pdf_import_mailer/es.yml b/config/locales/mailers/pdf_import_mailer/es.yml new file mode 100644 index 000000000..d5d423152 --- /dev/null +++ b/config/locales/mailers/pdf_import_mailer/es.yml @@ -0,0 +1,5 @@ +--- +es: + pdf_import_mailer: + next_steps: + subject: "Tu documento PDF ha sido analizado - %{product_name}" \ No newline at end of file diff --git a/config/locales/mailers/pdf_import_mailer/pl.yml b/config/locales/mailers/pdf_import_mailer/pl.yml new file mode 100644 index 000000000..713401ee8 --- /dev/null +++ b/config/locales/mailers/pdf_import_mailer/pl.yml @@ -0,0 +1,5 @@ +--- +pl: + pdf_import_mailer: + next_steps: + subject: "Twój dokument PDF został przeanalizowany - %{product_name}" diff --git a/config/locales/models/account/pl.yml b/config/locales/models/account/pl.yml new file mode 100644 index 000000000..885e5830a --- /dev/null +++ b/config/locales/models/account/pl.yml @@ -0,0 +1,23 @@ +--- +pl: + activerecord: + attributes: + account: + balance: Saldo + currency: Waluta + family: "%{moniker}" + family_id: "%{moniker}" + name: Nazwa + subtype: Podtyp + models: + account: Konto + account/bond: Obligacje + account/credit_card: Karta kredytowa + account/crypto: Kryptowaluty + account/depository: Konto bankowe + account/investment: Inwestycje + account/loan: Pożyczka + account/other_asset: Inne aktywa + account/other_liability: Inne zobowiązania + account/property: Nieruchomość + account/vehicle: Pojazd diff --git a/config/locales/models/address/pl.yml b/config/locales/models/address/pl.yml new file mode 100644 index 000000000..babc3e590 --- /dev/null +++ b/config/locales/models/address/pl.yml @@ -0,0 +1,11 @@ +--- +pl: + address: + attributes: + country: Kraj + line1: Ulica i numer + line2: Numer mieszkania (opcjonalnie) + locality: Miejscowość + postal_code: Kod pocztowy + region: Województwo/Region + format: "%{line1} %{line2}, %{locality}, %{region} %{postal_code} %{country}" diff --git a/config/locales/models/category/de.yml b/config/locales/models/category/de.yml new file mode 100644 index 000000000..58fa84e3e --- /dev/null +++ b/config/locales/models/category/de.yml @@ -0,0 +1,7 @@ +--- +de: + models: + category: + uncategorized: Nicht kategorisiert + other_investments: Sonstige Anlagen + investment_contributions: Anlagebeiträge diff --git a/config/locales/models/category/es.yml b/config/locales/models/category/es.yml new file mode 100644 index 000000000..90a24cd55 --- /dev/null +++ b/config/locales/models/category/es.yml @@ -0,0 +1,7 @@ +--- +es: + models: + category: + uncategorized: Sin clasificar + other_investments: Otras inversiones + investment_contributions: Aportaciones a inversiones \ No newline at end of file diff --git a/config/locales/models/category/pl.yml b/config/locales/models/category/pl.yml new file mode 100644 index 000000000..da69aeae9 --- /dev/null +++ b/config/locales/models/category/pl.yml @@ -0,0 +1,7 @@ +--- +pl: + models: + category: + uncategorized: Bez kategorii + other_investments: Inne inwestycje + investment_contributions: Wpłaty inwestycyjne diff --git a/config/locales/models/coinbase_account/de.yml b/config/locales/models/coinbase_account/de.yml new file mode 100644 index 000000000..9afe7a778 --- /dev/null +++ b/config/locales/models/coinbase_account/de.yml @@ -0,0 +1,5 @@ +--- +de: + coinbase: + processor: + paid_via: "Bezahlt über %{method}" diff --git a/config/locales/models/coinbase_account/es.yml b/config/locales/models/coinbase_account/es.yml new file mode 100644 index 000000000..88904258c --- /dev/null +++ b/config/locales/models/coinbase_account/es.yml @@ -0,0 +1,5 @@ +--- +es: + coinbase: + processor: + paid_via: "Pagado mediante %{method}" \ No newline at end of file diff --git a/config/locales/models/coinbase_account/pl.yml b/config/locales/models/coinbase_account/pl.yml new file mode 100644 index 000000000..52c5f099f --- /dev/null +++ b/config/locales/models/coinbase_account/pl.yml @@ -0,0 +1,5 @@ +--- +pl: + coinbase: + processor: + paid_via: Zapłacono przez %{method} diff --git a/config/locales/models/coinstats_item/de.yml b/config/locales/models/coinstats_item/de.yml new file mode 100644 index 000000000..d56bc04aa --- /dev/null +++ b/config/locales/models/coinstats_item/de.yml @@ -0,0 +1,12 @@ +--- +de: + models: + coinstats_item: + syncer: + importing_wallets: Wallets werden von CoinStats importiert... + checking_configuration: Wallet-Konfiguration wird geprüft... + wallets_need_setup: + one: "1 Wallet muss eingerichtet werden..." + other: "%{count} Wallets müssen eingerichtet werden..." + processing_holdings: Bestände werden verarbeitet... + calculating_balances: Salden werden berechnet... diff --git a/config/locales/models/coinstats_item/en.yml b/config/locales/models/coinstats_item/en.yml index e02b561cf..b5feb8217 100644 --- a/config/locales/models/coinstats_item/en.yml +++ b/config/locales/models/coinstats_item/en.yml @@ -3,8 +3,8 @@ en: models: coinstats_item: syncer: - importing_wallets: Importing wallets from CoinStats... - checking_configuration: Checking wallet configuration... - wallets_need_setup: "%{count} wallets need setup..." + importing_wallets: Importing crypto accounts from CoinStats... + checking_configuration: Checking CoinStats account configuration... + wallets_need_setup: "%{count} crypto accounts need setup..." processing_holdings: Processing holdings... calculating_balances: Calculating balances... diff --git a/config/locales/models/coinstats_item/es.yml b/config/locales/models/coinstats_item/es.yml new file mode 100644 index 000000000..c2bc6d3fc --- /dev/null +++ b/config/locales/models/coinstats_item/es.yml @@ -0,0 +1,10 @@ +--- +es: + models: + coinstats_item: + syncer: + importing_wallets: Importando carteras desde CoinStats... + checking_configuration: Comprobando la configuración de la cartera... + wallets_need_setup: "%{count} carteras necesitan configuración..." + processing_holdings: Procesando activos... + calculating_balances: Calculando saldos... \ No newline at end of file diff --git a/config/locales/models/coinstats_item/pl.yml b/config/locales/models/coinstats_item/pl.yml new file mode 100644 index 000000000..08b2f9105 --- /dev/null +++ b/config/locales/models/coinstats_item/pl.yml @@ -0,0 +1,14 @@ +--- +pl: + models: + coinstats_item: + syncer: + importing_wallets: Importowanie portfeli z CoinStats... + checking_configuration: Sprawdzanie konfiguracji portfela... + wallets_need_setup: + one: "%{count} portfel wymaga konfiguracji..." + few: "%{count} portfele wymagają konfiguracji..." + many: "%{count} portfeli wymaga konfiguracji..." + other: "%{count} portfela wymaga konfiguracji..." + processing_holdings: Przetwarzanie posiadanych aktywów... + calculating_balances: Obliczanie sald... diff --git a/config/locales/models/entry/pl.yml b/config/locales/models/entry/pl.yml new file mode 100644 index 000000000..a6b6c19b9 --- /dev/null +++ b/config/locales/models/entry/pl.yml @@ -0,0 +1,9 @@ +--- +pl: + activerecord: + errors: + models: + entry: + attributes: + base: + invalid_sell_quantity: nie można sprzedać %{sell_qty} udziałów %{ticker}, ponieważ posiadasz tylko %{current_qty} udziałów diff --git a/config/locales/models/import/pl.yml b/config/locales/models/import/pl.yml new file mode 100644 index 000000000..2c10762b6 --- /dev/null +++ b/config/locales/models/import/pl.yml @@ -0,0 +1,13 @@ +--- +pl: + activerecord: + attributes: + import: + currency: Waluta + number_format: Format liczby + errors: + models: + import: + attributes: + raw_file_str: + invalid_csv_format: nie jest prawidłowym formatem CSV diff --git a/config/locales/models/provider_warnings/pl.yml b/config/locales/models/provider_warnings/pl.yml new file mode 100644 index 000000000..89fa5c9aa --- /dev/null +++ b/config/locales/models/provider_warnings/pl.yml @@ -0,0 +1,4 @@ +--- +pl: + provider_warnings: + limited_investment_data: Dane inwestycyjne od tego dostawcy są ograniczone. Etykiety transakcji (Kupno, Sprzedaż, Dywidenda) są niedostępne, co może wpłynąć na dokładność budżetu. Rozważ tworzenie reguł wykluczenia lub kategoryzacji transakcji inwestycyjnych. diff --git a/config/locales/models/time_series/value/pl.yml b/config/locales/models/time_series/value/pl.yml new file mode 100644 index 000000000..c63b9ca38 --- /dev/null +++ b/config/locales/models/time_series/value/pl.yml @@ -0,0 +1,9 @@ +--- +pl: + activemodel: + errors: + models: + time_series/value: + attributes: + value: + must_be_a_money_or_numeric: musi być typu Money lub Numeric diff --git a/config/locales/models/transaction/en.yml b/config/locales/models/transaction/en.yml new file mode 100644 index 000000000..7359a69b4 --- /dev/null +++ b/config/locales/models/transaction/en.yml @@ -0,0 +1,11 @@ +--- +en: + activerecord: + errors: + models: + transaction: + attributes: + attachments: + too_many: "cannot exceed %{max} files per transaction" + too_large: "file %{index} is too large (maximum %{max_mb}MB)" + invalid_format: "file %{index} has unsupported format (%{file_format})" diff --git a/config/locales/models/transaction/pl.yml b/config/locales/models/transaction/pl.yml new file mode 100644 index 000000000..ec6c6304a --- /dev/null +++ b/config/locales/models/transaction/pl.yml @@ -0,0 +1,11 @@ +--- +pl: + activerecord: + errors: + models: + transaction: + attributes: + attachments: + too_many: nie może przekraczać %{max} plików na transakcję + too_large: plik %{index} jest zbyt duży (maksymalnie %{max_mb}MB) + invalid_format: plik %{index} ma nieobsługiwany format (%{file_format}) diff --git a/config/locales/models/transaction/pt-BR.yml b/config/locales/models/transaction/pt-BR.yml new file mode 100644 index 000000000..725f792b9 --- /dev/null +++ b/config/locales/models/transaction/pt-BR.yml @@ -0,0 +1,11 @@ +--- +pt-BR: + activerecord: + errors: + models: + transaction: + attributes: + attachments: + too_many: "não é possível exceder %{max} arquivos por transação" + too_large: "O arquivo %{index} é muito grande (máximo de %{max_mb}MB)" + invalid_format: "O arquivo %{index} possui um formato não suportado (%{file_format})" diff --git a/config/locales/models/transfer/pl.yml b/config/locales/models/transfer/pl.yml new file mode 100644 index 000000000..b61fe86a5 --- /dev/null +++ b/config/locales/models/transfer/pl.yml @@ -0,0 +1,18 @@ +--- +pl: + activerecord: + errors: + models: + transfer: + attributes: + base: + inflow_cannot_be_in_multiple_transfers: Transakcja wpływu nie może być częścią wielu przelewów + must_be_from_different_accounts: Przelew musi dotyczyć różnych kont + must_be_from_same_family: Przelew musi być w ramach tego samego gospodarstwa domowego + must_be_within_date_range: Daty transakcji przelewu muszą być w ciągu 4 dni od siebie + must_have_opposite_amounts: Transakcje przelewu muszą mieć przeciwne kwoty + must_have_single_currency: Przelew musi być w jednej walucie + outflow_cannot_be_in_multiple_transfers: Transakcja wypływu nie może być częścią wielu przelewów + transfer: + name: Przelew do %{to_account} + payment_name: Płatność do %{to_account} diff --git a/config/locales/models/trend/pl.yml b/config/locales/models/trend/pl.yml new file mode 100644 index 000000000..221d4c262 --- /dev/null +++ b/config/locales/models/trend/pl.yml @@ -0,0 +1,13 @@ +--- +pl: + activemodel: + errors: + models: + trend: + attributes: + current: + must_be_of_the_same_type_as_previous: musi być tego samego typu co poprzednia wartość + must_be_of_type_money_numeric_or_nil: musi być typu Money, Numeric lub nil + previous: + must_be_of_the_same_type_as_current: musi być tego samego typu co aktualna wartość + must_be_of_type_money_numeric_or_nil: musi być typu Money, Numeric lub nil diff --git a/config/locales/models/user/pl.yml b/config/locales/models/user/pl.yml new file mode 100644 index 000000000..2a696f2f6 --- /dev/null +++ b/config/locales/models/user/pl.yml @@ -0,0 +1,20 @@ +--- +pl: + activerecord: + attributes: + user: + email: Email + family: "%{moniker}" + family_id: "%{moniker}" + first_name: Imię + last_name: Nazwisko + password: Hasło + password_confirmation: Potwierdzenie hasła + errors: + models: + user: + attributes: + base: + cannot_deactivate_admin_with_other_users: Administrator nie może usunąć konta, gdy obecni są inni użytkownicy. Najpierw usuń wszystkich członków. + profile_image: + invalid_file_size: rozmiar pliku nie może przekraczać %{max_megabytes}MB diff --git a/config/locales/views/account_sharings/en.yml b/config/locales/views/account_sharings/en.yml new file mode 100644 index 000000000..2a219a054 --- /dev/null +++ b/config/locales/views/account_sharings/en.yml @@ -0,0 +1,29 @@ +--- +en: + account_sharings: + show: + title: Account Sharing + subtitle: Control who can see and interact with this account + member: Member + permission: Permission + shared: Shared + no_members: No other members in your %{moniker} to share with + permissions: + full_control: Full control + full_control_description: Can view, edit, and manage transactions + read_write: Can annotate + read_write_description: Can categorize, tag, and add notes + read_only: View only + read_only_description: Can only view account data + save: Save sharing settings + owner_label: "Owner: %{name}" + shared_with_count: + one: Shared with 1 member + other: "Shared with %{count} members" + include_in_finances: Include in my budgets & reports + exclude_from_finances: Exclude from my budgets & reports + finance_toggle_description: Count this account in your net worth, budgets, and reports + update: + success: Sharing settings updated + not_owner: Only the account owner can manage sharing + finance_toggle_success: Finance inclusion preference updated diff --git a/config/locales/views/account_sharings/pl.yml b/config/locales/views/account_sharings/pl.yml new file mode 100644 index 000000000..010b2b54b --- /dev/null +++ b/config/locales/views/account_sharings/pl.yml @@ -0,0 +1,31 @@ +--- +pl: + account_sharings: + show: + title: Udostępnianie konta + subtitle: Zarządzaj tym, kto może przeglądać i używać tego konta + member: Członek + permission: Uprawnienie + shared: Udostępnione + no_members: Brak innych członków w Twoim %{moniker}, którym można udostępnić + permissions: + full_control: Pełna kontrola + full_control_description: Może przeglądać, edytować i zarządzać transakcjami + read_write: Odczyt i zapis + read_write_description: Może kategoryzować, tagować i dodawać notatki + read_only: Tylko podgląd + read_only_description: Może tylko przeglądać dane konta + save: Zapisz ustawienia udostępniania + owner_label: 'Właściciel: %{name}' + shared_with_count: + one: Udostępniono 1 członkowi + few: Udostępniono %{count} członkom + many: Udostępniono %{count} członkom + other: Udostępniono %{count} członkom + include_in_finances: Uwzględniaj w moich budżetach i raportach + exclude_from_finances: Wykluczaj z moich budżetów i raportów + finance_toggle_description: Uwzględniaj to konto w majątku netto, budżetach i raportach + update: + success: Zaktualizowano ustawienia udostępniania + not_owner: Tylko właściciel konta może zarządzać udostępnianiem + finance_toggle_success: Zaktualizowano preferencję uwzględniania w finansach diff --git a/config/locales/views/account_sharings/pt-BR.yml b/config/locales/views/account_sharings/pt-BR.yml new file mode 100644 index 000000000..2fa59ae1d --- /dev/null +++ b/config/locales/views/account_sharings/pt-BR.yml @@ -0,0 +1,29 @@ +--- +pt-BR: + account_sharings: + show: + title: Compartilhamento de conta + subtitle: Controle quem pode ver e interagir com esta conta. + member: Membro + permission: Permissão + shared: Compartilhada + no_members: Não há outros membros em seu %{moniker} com quem compartilhar. + permissions: + full_control: Controle total + full_control_description: É possível visualizar, editar e gerenciar transações. + read_write: Pode fazer anotações + read_write_description: É possível categorizar, etiquetar e adicionar notas. + read_only: Somente visualização + read_only_description: Só é possível visualizar os dados da conta. + save: Salvar configurações de compartilhamento + owner_label: "Proprietário: %{name}" + shared_with_count: + one: Compartilhado com 1 membro + other: "Compartilhado com %{count} membros" + include_in_finances: Incluir nos meus orçamentos e relatórios + exclude_from_finances: Excluir dos meus orçamentos e relatórios + finance_toggle_description: Inclua essa conta em seu patrimônio líquido, orçamentos e relatórios. + update: + success: Configurações de compartilhamento atualizadas + not_owner: Somente o proprietário da conta pode gerenciar o compartilhamento. + finance_toggle_success: Preferência de inclusão financeira atualizada diff --git a/config/locales/views/accounts/de.yml b/config/locales/views/accounts/de.yml index 0e9196476..f163a31b1 100644 --- a/config/locales/views/accounts/de.yml +++ b/config/locales/views/accounts/de.yml @@ -1,34 +1,58 @@ +--- de: accounts: account: + edit: Bearbeiten link_lunchflow: Mit Lunch Flow verknüpfen + link_provider: Mit Provider verknüpfen + unlink_provider: Von Provider trennen troubleshoot: Fehlerbehebung + enable: Konto aktivieren + disable: Konto deaktivieren + set_default: Als Standard festlegen + remove_default: Standard aufheben + default_label: Standard + delete: Konto löschen chart: data_not_available: Für den ausgewählten Zeitraum sind keine Daten verfügbar create: success: "%{type}-Konto erstellt" + set_default: + depository_only: "Nur Bargeld- und Kreditkartenkonten können als Standard festgelegt werden." destroy: success: "%{type}-Konto zur Löschung vorgemerkt" + cannot_delete_linked: "Ein verknüpftes Konto kann nicht gelöscht werden. Bitte trennen Sie es zuerst." empty: empty_message: Füge ein Konto über eine Verbindung, einen Import oder manuell hinzu new_account: Neues Konto no_accounts: Noch keine Konten vorhanden form: - balance: Aktueller Kontostand + balance: "Kontostand zum Datum:" + opening_balance_date_label: Eröffnungsdatum des Kontostands name_label: Kontoname name_placeholder: Beispielkontoname + additional_details: Weitere Angaben + institution_name_label: Name der Institution + institution_name_placeholder: "z. B. Chase Bank" + institution_domain_label: Domain der Institution + institution_domain_placeholder: "z. B. chase.com" + notes_label: Notizen + notes_placeholder: Zusätzliche Informationen wie Kontonummern, Sort codes, IBAN, Routing-Nummern usw. index: accounts: Konten manual_accounts: other_accounts: Andere Konten new_account: Neues Konto sync: Alle synchronisieren + sync_all: + syncing: "Konten werden synchronisiert..." new: import_accounts: Konten importieren method_selector: connected_entry: Konto verknüpfen connected_entry_eu: EU-Konto verknüpfen link_with_provider: "Mit %{provider} verknüpfen" + lunchflow_entry: Lunch-Flow-Konto verknüpfen manual_entry: Kontostand manuell eingeben title: Wie möchtest du es hinzufügen title: Was möchtest du hinzufügen @@ -36,13 +60,22 @@ de: activity: amount: Betrag balance: Kontostand + confirmed: Bestätigt date: Datum entries: Buchungen entry: Buchung + filter: Filtern new: Neu + new_activity: Neue Aktivität new_balance: Neuer Kontostand + new_trade: Neuer Trade new_transaction: Neue Transaktion + new_transfer: Neue Überweisung no_entries: Keine Buchungen gefunden + pending: Ausstehend + search: + placeholder: Buchungen nach Name suchen + status: Status title: Aktivität chart: balance: Kontostand @@ -53,6 +86,8 @@ de: confirm_title: Konto löschen edit: Bearbeiten import: Transaktionen importieren + import_trades: Trades importieren + import_transactions: Transaktionen importieren manage: Konten verwalten update: success: "%{type}-Konto aktualisiert" @@ -78,6 +113,42 @@ de: credit_card: Kreditkarte loan: Darlehen other_liability: Sonstige Verbindlichkeit + subtype_regions: + us: Vereinigte Staaten + uk: Vereinigtes Königreich + ca: Kanada + au: Australien + eu: Europa + generic: Generisch + tax_treatments: + taxable: Versteuerbar + tax_deferred: Steuerlich aufgeschoben + tax_exempt: Steuerfrei + tax_advantaged: Steuerbegünstigt + tax_treatment_descriptions: + taxable: Gewinne werden bei Realisierung besteuert + tax_deferred: Beiträge abzugsfähig, Besteuerung bei Auszahlung + tax_exempt: Beiträge nach Steuer, Gewinne nicht besteuert + tax_advantaged: Besondere Steuervorteile unter Bedingungen + confirm_unlink: + title: Konto vom Provider trennen? + description_html: "Sie sind dabei, %{account_name} von %{provider_name} zu trennen. Das Konto wird zu einem manuellen Konto." + warning_title: Was das bedeutet + warning_no_sync: Das Konto wird nicht mehr automatisch mit dem Provider synchronisiert + warning_manual_updates: Sie müssen Buchungen und Salden manuell pflegen + warning_transactions_kept: Bestehende Buchungen und Salden bleiben erhalten + warning_can_delete: Nach dem Trennen können Sie das Konto bei Bedarf löschen + confirm_button: Bestätigen und trennen + unlink: + success: "Konto erfolgreich getrennt. Es ist jetzt ein manuelles Konto." + not_linked: "Konto ist mit keinem Provider verknüpft" + error: "Konto konnte nicht getrennt werden: %{error}" + generic_error: "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut." + select_provider: + title: Provider zum Verknüpfen auswählen + description: "Wählen Sie den Provider, mit dem %{account_name} verknüpft werden soll" + already_linked: "Konto ist bereits mit einem Provider verknüpft" + no_providers: "Derzeit sind keine Provider konfiguriert" email_confirmations: new: diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml index ef748b6b7..23af6cfab 100644 --- a/config/locales/views/accounts/en.yml +++ b/config/locales/views/accounts/en.yml @@ -1,24 +1,37 @@ --- en: accounts: + not_authorized: "You don't have permission to manage this account" account: + edit: Edit link_lunchflow: Link with Lunch Flow link_provider: Link with provider unlink_provider: Unlink from provider troubleshoot: Troubleshoot + enable: Enable account + disable: Disable account + set_default: Set as default + remove_default: Unset default + default_label: Default + delete: Delete account + sharing: Sharing chart: data_not_available: Data not available for the selected period create: success: "%{type} account created" + set_default: + depository_only: "Only cash and credit card accounts can be set as default." destroy: success: "%{type} account scheduled for deletion" cannot_delete_linked: "Cannot delete a linked account. Please unlink it first." + failed: "Resource deletion failed. Try again later." empty: empty_message: Add an account either via connection, importing or entering manually. new_account: New account no_accounts: No accounts yet form: - balance: Current balance + balance: "Balance on date:" + opening_balance_date_label: Opening balance date name_label: Account name name_placeholder: Example account name additional_details: Additional details diff --git a/config/locales/views/accounts/es.yml b/config/locales/views/accounts/es.yml index c99a987a1..613bf93ce 100644 --- a/config/locales/views/accounts/es.yml +++ b/config/locales/views/accounts/es.yml @@ -2,14 +2,26 @@ es: accounts: account: + edit: Editar link_lunchflow: Vincular con Lunch Flow + link_provider: Vincular con proveedor + unlink_provider: Desvincular de proveedor troubleshoot: Solucionar problemas + enable: Activar cuenta + disable: Desactivar cuenta + set_default: Establecer como predeterminada + remove_default: Quitar predeterminada + default_label: Predeterminada + delete: Eliminar cuenta chart: data_not_available: Datos no disponibles para el período seleccionado create: success: "Cuenta %{type} creada" + set_default: + depository_only: "Solo las cuentas de efectivo y tarjeta de crédito pueden establecerse como predeterminadas." destroy: success: "Cuenta %{type} programada para eliminación" + cannot_delete_linked: "No se puede eliminar una cuenta vinculada. Por favor, desvincúlela primero." empty: empty_message: Añade una cuenta mediante conexión, importación o introducción manual. new_account: Nueva cuenta @@ -18,12 +30,21 @@ es: balance: Saldo actual name_label: Nombre de la cuenta name_placeholder: Ejemplo de nombre de cuenta + additional_details: Detalles adicionales + institution_name_label: Nombre de la institución + institution_name_placeholder: ej. Chase Bank + institution_domain_label: Dominio de la institución + institution_domain_placeholder: ej. chase.com + notes_label: Notas + notes_placeholder: Guarda información adicional como números de cuenta, códigos de sucursal, IBAN, números de ruta, etc. index: accounts: Cuentas manual_accounts: other_accounts: Otras cuentas new_account: Nueva cuenta sync: Sincronizar todo + sync_all: + syncing: "Sincronizando cuentas..." new: import_accounts: Importar cuentas method_selector: @@ -38,15 +59,22 @@ es: activity: amount: Cantidad balance: Saldo + confirmed: Confirmado date: Fecha entries: entradas entry: entrada + filter: Filtrar new: Nuevo + new_activity: Nueva actividad new_balance: Nuevo saldo + new_trade: Nueva operación new_transaction: Nueva transacción + new_transfer: Nueva transferencia no_entries: No se encontraron entradas + pending: Pendiente search: placeholder: Buscar entradas por nombre + status: Estado title: Actividad chart: balance: Saldo @@ -57,6 +85,8 @@ es: confirm_title: ¿Eliminar cuenta? edit: Editar import: Importar transacciones + import_trades: Importar operaciones + import_transactions: Importar transacciones manage: Gestionar cuentas update: success: "Cuenta %{type} actualizada" @@ -82,8 +112,44 @@ es: credit_card: Tarjeta de crédito loan: Préstamo other_liability: Otra deuda + tax_treatments: + taxable: Sujeto a impuestos + tax_deferred: Impuestos diferidos + tax_exempt: Exento de impuestos + tax_advantaged: Ventaja fiscal + tax_treatment_descriptions: + taxable: Ganancias gravadas al realizarse + tax_deferred: Aportaciones deducibles, impuestos al retirar + tax_exempt: Aportaciones después de impuestos, ganancias exentas + tax_advantaged: Beneficios fiscales especiales con condiciones + subtype_regions: + us: Estados Unidos + uk: Reino Unido + ca: Canadá + au: Australia + eu: Europa + generic: General + confirm_unlink: + title: ¿Desvincular cuenta del proveedor? + description_html: "Estás a punto de desvincular %{account_name} de %{provider_name}. Esto la convertirá en una cuenta manual." + warning_title: Qué significa esto + warning_no_sync: La cuenta dejará de sincronizarse automáticamente con tu proveedor + warning_manual_updates: Deberás añadir transacciones y actualizar saldos manualmente + warning_transactions_kept: Se conservarán todas las transacciones y saldos existentes + warning_can_delete: Tras desvincularla, podrás eliminar la cuenta si es necesario + confirm_button: Confirmar y desvincular + unlink: + success: "Cuenta desvinculada correctamente. Ahora es una cuenta manual." + not_linked: "La cuenta no está vinculada a un proveedor" + error: "Error al desvincular la cuenta: %{error}" + generic_error: "Ha ocurrido un error inesperado. Por favor, inténtalo de nuevo." + select_provider: + title: Selecciona un proveedor para vincular + description: "Elige qué proveedor quieres usar para vincular %{account_name}" + already_linked: "La cuenta ya está vinculada a un proveedor" + no_providers: "No hay proveedores configurados actualmente" email_confirmations: new: invalid_token: Enlace de confirmación inválido o caducado. - success_login: Tu correo electrónico ha sido confirmado. Por favor, inicia sesión con tu nueva dirección de correo electrónico. + success_login: Tu correo electrónico ha sido confirmado. Por favor, inicia sesión con tu nueva dirección de correo electrónico. \ No newline at end of file diff --git a/config/locales/views/accounts/fr.yml b/config/locales/views/accounts/fr.yml index 3c9dae3bb..7d2eafcb8 100644 --- a/config/locales/views/accounts/fr.yml +++ b/config/locales/views/accounts/fr.yml @@ -2,14 +2,23 @@ fr: accounts: account: + edit: Modifier link_lunchflow: Lier avec Lunch Flow link_provider: Lier avec un fournisseur unlink_provider: Délier du fournisseur troubleshoot: Dépannage + enable: Activer le compte + disable: Désactiver le compte + set_default: Définir par défaut + remove_default: Retirer par défaut + default_label: Par défaut + delete: Supprimer le compte chart: data_not_available: Données non disponibles pour la période sélectionnée create: success: "Compte %{type} créé" + set_default: + depository_only: "Seuls les comptes de liquidités et de carte de crédit peuvent être définis par défaut." destroy: success: "Le compte %{type} a été préparé à la suppression" cannot_delete_linked: "Impossible de supprimer un compte lié. Veuillez d'abord le délier." diff --git a/config/locales/views/accounts/pl.yml b/config/locales/views/accounts/pl.yml new file mode 100644 index 000000000..88418d805 --- /dev/null +++ b/config/locales/views/accounts/pl.yml @@ -0,0 +1,158 @@ +--- +pl: + accounts: + not_authorized: Nie masz uprawnień do zarządzania tym kontem + account: + edit: Edytuj + link_lunchflow: Połącz z Lunch Flow + link_provider: Połącz z dostawcą + unlink_provider: Odłącz od dostawcy + troubleshoot: Rozwiąż problem + enable: Włącz konto + disable: Wyłącz konto + set_default: Ustaw jako domyślne + remove_default: Usuń status domyślnego + default_label: Domyślne + delete: Usuń konto + sharing: Udostępnianie + chart: + data_not_available: Dane niedostępne dla wybranego okresu + create: + success: Utworzono konto %{type} + set_default: + depository_only: Tylko konta gotówkowe i karty kredytowe mogą być ustawione jako domyślne. + destroy: + success: Zaplanowano usunięcie konta %{type} + cannot_delete_linked: Nie można usunąć połączonego konta. Najpierw je odłącz. + empty: + empty_message: Dodaj konto przez połączenie, import lub ręczne wprowadzenie. + new_account: Nowe konto + no_accounts: Brak kont + form: + balance: 'Saldo na dzień:' + opening_balance_date_label: Data salda początkowego + name_label: Nazwa konta + name_placeholder: Przykładowa nazwa konta + additional_details: Dodatkowe informacje + institution_name_label: Nazwa instytucji + institution_name_placeholder: np. PKO Bank Polski + institution_domain_label: Domena instytucji + institution_domain_placeholder: np. pkobp.pl + notes_label: Notatki + notes_placeholder: Zapisz dodatkowe informacje, takie jak numery kont, kody rozliczeniowe, IBAN, numery routingowe itp. + index: + accounts: Konta + manual_accounts: + other_accounts: Inne konta + new_account: Nowe konto + sync: Synchronizuj wszystko + sync_all: + syncing: Trwa synchronizacja kont... + new: + import_accounts: Importuj konta + method_selector: + connected_entry: Połącz konto + connected_entry_eu: Połącz konto z UE + link_with_provider: Połącz z %{provider} + lunchflow_entry: Połącz konto Lunch Flow + manual_entry: Wprowadź saldo konta + title: Jak chcesz je dodać? + title: Co chcesz dodać? + show: + activity: + amount: Kwota + balance: Saldo + confirmed: Potwierdzone + date: Data + entries: wpisy + entry: wpis + filter: Filtr + new: Nowy + new_activity: Nowa aktywność + new_balance: Nowe saldo + new_trade: Nowa transakcja giełdowa + new_transaction: Nowa transakcja + new_transfer: Nowy przelew + no_entries: Nie znaleziono wpisów + pending: Oczekujące + search: + placeholder: Szukaj wpisów po nazwie + status: Status + title: Aktywność + chart: + balance: Saldo + owed: Kwota zadłużenia + menu: + confirm_accept: Usuń "%{name}" + confirm_body_html: "

    Usuwając to konto, usuniesz jego historię wartości, co wpłynie na różne aspekty Twojego ogólnego bilansu. Ta akcja będzie miała bezpośredni wpływ na obliczenia majątku netto i wykresy konta.


    Po usunięciu nie będzie możliwości przywrócenia informacji o koncie, ponieważ trzeba będzie dodać je ponownie jako nowe konto.

    " + confirm_title: Usunąć konto? + edit: Edytuj + import: Importuj transakcje + import_trades: Importuj transakcje giełdowe + import_transactions: Importuj transakcje + manage: Zarządzaj kontami + update: + success: Zaktualizowano konto %{type} + sidebar: + missing_data: Brak danych historycznych + missing_data_description: "%{product} używa zewnętrznych dostawców do pobierania historycznych kursów walut, cen papierów wartościowych i innych danych. Te dane są wymagane do dokładnego obliczania historycznych sald kont." + configure_providers: Skonfiguruj tutaj swoich dostawców. + tabs: + all: Wszystkie + assets: Aktywa + debts: Zobowiązania + new_asset: Nowe aktywo + new_debt: Nowe zobowiązanie + new_account: Nowe konto + new_account_group: Nowe %{account_group} + types: + depository: Gotówka + investment: Inwestycje + crypto: Kryptowaluty + property: Nieruchomość + vehicle: Pojazd + bond: Obligacje + other_asset: Inne aktywo + credit_card: Karta kredytowa + loan: Pożyczka + other_liability: Inne zobowiązanie + tax_treatments: + taxable: Opodatkowane + tax_deferred: Odroczony podatek + tax_exempt: Zwolnione z podatku + tax_advantaged: Preferencyjne podatkowo + tax_treatment_descriptions: + taxable: Zyski opodatkowane przy realizacji + tax_deferred: Wpłaty odliczalne, opodatkowanie przy wypłacie + tax_exempt: Wpłaty po opodatkowaniu, zyski nieopodatkowane + tax_advantaged: Specjalne korzyści podatkowe pod pewnymi warunkami + subtype_regions: + us: Stany Zjednoczone + uk: Wielka Brytania + ca: Kanada + au: Australia + eu: Europa + generic: Ogólne + confirm_unlink: + title: Odłączyć konto od dostawcy? + description_html: Zaraz odłączysz %{account_name} od %{provider_name}. Konto zostanie przekształcone w konto ręczne. + warning_title: Co to oznacza + warning_no_sync: Konto nie będzie już synchronizować się automatycznie z dostawcą + warning_manual_updates: Trzeba będzie ręcznie dodawać transakcje i aktualizować salda + warning_transactions_kept: Wszystkie istniejące transakcje i salda zostaną zachowane + warning_can_delete: Po odłączeniu będzie można usunąć konto, jeśli zajdzie taka potrzeba + confirm_button: Potwierdź i odłącz + unlink: + success: Konto zostało pomyślnie odłączone. Teraz jest kontem ręcznym. + not_linked: Konto nie jest połączone z dostawcą + error: 'Nie udało się odłączyć konta: %{error}' + generic_error: Wystąpił nieoczekiwany błąd. Spróbuj ponownie. + select_provider: + title: Wybierz dostawcę do połączenia + description: Wybierz dostawcę, którego chcesz użyć do połączenia %{account_name} + already_linked: Konto jest już połączone z dostawcą + no_providers: Obecnie nie skonfigurowano żadnych dostawców + email_confirmations: + new: + invalid_token: Nieprawidłowy lub wygasły link potwierdzający. + success_login: Twój adres e-mail został potwierdzony. Zaloguj się przy użyciu nowego adresu e-mail. diff --git a/config/locales/views/admin/invitations/en.yml b/config/locales/views/admin/invitations/en.yml new file mode 100644 index 000000000..389566e19 --- /dev/null +++ b/config/locales/views/admin/invitations/en.yml @@ -0,0 +1,8 @@ +--- +en: + admin: + invitations: + destroy: + success: "Invitation deleted." + destroy_all: + success: "All invitations for this family have been deleted." diff --git a/config/locales/views/admin/invitations/pl.yml b/config/locales/views/admin/invitations/pl.yml new file mode 100644 index 000000000..162ac466e --- /dev/null +++ b/config/locales/views/admin/invitations/pl.yml @@ -0,0 +1,8 @@ +--- +pl: + admin: + invitations: + destroy: + success: Zaproszenie zostało usunięte. + destroy_all: + success: Wszystkie zaproszenia dla tej rodziny zostały usunięte. diff --git a/config/locales/views/admin/invitations/pt-BR.yml b/config/locales/views/admin/invitations/pt-BR.yml new file mode 100644 index 000000000..1ea583b2f --- /dev/null +++ b/config/locales/views/admin/invitations/pt-BR.yml @@ -0,0 +1,8 @@ +--- +pt-BR: + admin: + invitations: + destroy: + success: "Convite excluído." + destroy_all: + success: "Todos os convites para esta família foram cancelados." diff --git a/config/locales/views/admin/sso_providers/de.yml b/config/locales/views/admin/sso_providers/de.yml new file mode 100644 index 000000000..4f46a9b61 --- /dev/null +++ b/config/locales/views/admin/sso_providers/de.yml @@ -0,0 +1,115 @@ +--- +de: + admin: + unauthorized: "Sie sind nicht berechtigt, auf diesen Bereich zuzugreifen." + sso_providers: + index: + title: "SSO-Provider" + description: "Single-Sign-On-Authentifizierungsprovider für Ihre Instanz verwalten" + add_provider: "Provider hinzufügen" + no_providers_title: "Keine SSO-Provider" + no_providers_message: "Fügen Sie Ihren ersten SSO-Provider hinzu." + note: "Änderungen an SSO-Providern erfordern einen Neustart des Servers. Alternativ aktivieren Sie das Feature AUTH_PROVIDERS_SOURCE=db, um Provider dynamisch aus der Datenbank zu laden." + table: + name: "Name" + strategy: "Strategie" + status: "Status" + issuer: "Issuer" + actions: "Aktionen" + enabled: "Aktiviert" + disabled: "Deaktiviert" + legacy_providers_title: "Umgebungskonfigurierte Provider" + legacy_providers_notice: "Diese Provider werden über Umgebungsvariablen oder YAML konfiguriert und können nicht über diese Oberfläche verwaltet werden. Zur Verwaltung hier migrieren Sie sie zu datenbankgestützten Providern (AUTH_PROVIDERS_SOURCE=db) und legen Sie sie in der Oberfläche neu an." + env_configured: "Env/YAML" + new: + title: "SSO-Provider hinzufügen" + description: "Neuen Single-Sign-On-Authentifizierungsprovider konfigurieren" + edit: + title: "SSO-Provider bearbeiten" + description: "Konfiguration für %{label} aktualisieren" + create: + success: "SSO-Provider wurde erfolgreich erstellt." + update: + success: "SSO-Provider wurde erfolgreich aktualisiert." + destroy: + success: "SSO-Provider wurde erfolgreich gelöscht." + confirm: "Möchten Sie diesen Provider wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden." + toggle: + success_enabled: "SSO-Provider wurde erfolgreich aktiviert." + success_disabled: "SSO-Provider wurde erfolgreich deaktiviert." + confirm_enable: "Möchten Sie diesen Provider aktivieren?" + confirm_disable: "Möchten Sie diesen Provider deaktivieren?" + form: + basic_information: "Grundinformationen" + oauth_configuration: "OAuth/OIDC-Konfiguration" + strategy_label: "Strategie" + strategy_help: "Die zu verwendende Authentifizierungsstrategie" + name_label: "Name" + name_placeholder: "z. B. openid_connect, keycloak, authentik" + name_help: "Eindeutige Kennung (nur Kleinbuchstaben, Zahlen, Unterstriche)" + label_label: "Bezeichnung" + label_placeholder: "z. B. Anmelden mit Keycloak" + label_help: "Button-Text für Benutzer" + icon_label: "Icon" + icon_placeholder: "z. B. key, google, github" + icon_help: "Lucide-Icon-Name (optional)" + enabled_label: "Diesen Provider aktivieren" + enabled_help: "Benutzer können sich bei Aktivierung mit diesem Provider anmelden" + issuer_label: "Issuer" + issuer_placeholder: "https://accounts.google.com" + issuer_help: "OIDC-Issuer-URL (validiert .well-known/openid-configuration)" + client_id_label: "Client ID" + client_id_placeholder: "your-client-id" + client_id_help: "OAuth-Client-ID Ihres Identitätsanbieters" + client_secret_label: "Client Secret" + client_secret_placeholder_new: "your-client-secret" + client_secret_placeholder_existing: "••••••••••••••••" + client_secret_help: "OAuth-Client-Secret (verschlüsselt in der Datenbank)" + client_secret_help_existing: " – leer lassen, um bestehendes beizubehalten" + redirect_uri_label: "Redirect URI" + redirect_uri_placeholder: "https://yourdomain.com/auth/openid_connect/callback" + redirect_uri_help: "Callback-URL, die beim Identitätsanbieter eingetragen werden muss" + copy_button: "Kopieren" + cancel: "Abbrechen" + submit: "Provider speichern" + errors_title: "%{count} Fehler verhinderte das Speichern dieses Providers:" + provisioning_title: "Benutzer-Bereitstellung" + default_role_label: "Standardrolle für neue Benutzer" + default_role_help: "Rolle für per JIT-SSO bereitgestellte Benutzer. Standard: Mitglied." + role_guest: "Gast" + role_member: "Mitglied" + role_admin: "Admin" + role_super_admin: "Super-Admin" + role_mapping_title: "Gruppen-Rollen-Zuordnung (optional)" + role_mapping_help: "IdP-Gruppen/Claims auf Anwendungsrollen abbilden. Höchste passende Rolle wird zugewiesen. Leer = Standardrolle oben." + super_admin_groups: "Super-Admin-Gruppen" + admin_groups: "Admin-Gruppen" + guest_groups: "Gast-Gruppen" + member_groups: "Mitglieder-Gruppen" + groups_help: "Kommagetrennte IdP-Gruppennamen. * = alle Gruppen." + advanced_title: "Erweiterte OIDC-Einstellungen" + scopes_label: "Benutzerdefinierte Scopes" + scopes_help: "Leerzeichengetrennte OIDC-Scopes. Leer = Standard (openid email profile). 'groups' für Gruppen-Claims." + prompt_label: "Authentifizierungs-Prompt" + prompt_default: "Standard (IdP entscheidet)" + prompt_login: "Login erzwingen (erneut anmelden)" + prompt_consent: "Zustimmung erzwingen (erneut autorisieren)" + prompt_select_account: "Kontoauswahl" + prompt_none: "Kein Prompt (stille Anmeldung)" + prompt_help: "Steuert, wie der IdP den Benutzer während der Anmeldung auffordert." + test_connection: "Verbindung testen" + saml_configuration: "SAML-Konfiguration" + idp_metadata_url: "IdP-Metadaten-URL" + idp_metadata_url_help: "URL zu den SAML-Metadaten Ihres IdP. Andere SAML-Einstellungen werden dann automatisch gesetzt." + manual_saml_config: "Manuelle Konfiguration (ohne Metadaten-URL)" + manual_saml_help: "Nur verwenden, wenn Ihr IdP keine Metadaten-URL bereitstellt." + idp_sso_url: "IdP-SSO-URL" + idp_slo_url: "IdP-SLO-URL (optional)" + idp_certificate: "IdP-Zertifikat" + idp_certificate_help: "X.509-Zertifikat im PEM-Format. Erforderlich ohne Metadaten-URL." + idp_cert_fingerprint: "Zertifikats-Fingerabdruck (Alternative)" + name_id_format: "NameID-Format" + name_id_email: "E-Mail-Adresse (Standard)" + name_id_persistent: "Persistent" + name_id_transient: "Transient" + name_id_unspecified: "Nicht angegeben" diff --git a/config/locales/views/admin/sso_providers/es.yml b/config/locales/views/admin/sso_providers/es.yml new file mode 100644 index 000000000..ab9567955 --- /dev/null +++ b/config/locales/views/admin/sso_providers/es.yml @@ -0,0 +1,115 @@ +--- +es: + admin: + unauthorized: "No tienes autorización para acceder a esta área." + sso_providers: + index: + title: "Proveedores de SSO" + description: "Gestiona los proveedores de autenticación de inicio de sesión único para tu instancia" + add_provider: "Añadir proveedor" + no_providers_title: "No hay proveedores de SSO" + no_providers_message: "Empieza añadiendo tu primer proveedor de SSO." + note: "Los cambios en los proveedores de SSO requieren un reinicio del servidor para surtir efecto. Alternativamente, activa la función AUTH_PROVIDERS_SOURCE=db para cargar los proveedores desde la base de datos de forma dinámica." + table: + name: "Nombre" + strategy: "Estrategia" + status: "Estado" + issuer: "Emisor (Issuer)" + actions: "Acciones" + enabled: "Activado" + disabled: "Desactivado" + legacy_providers_title: "Proveedores configurados por entorno" + legacy_providers_notice: "Estos proveedores se configuran mediante variables de entorno o YAML y no pueden gestionarse a través de esta interfaz. Para gestionarlos aquí, mígralos a proveedores respaldados por la base de datos activando AUTH_PROVIDERS_SOURCE=db y recreándolos en la interfaz de usuario." + env_configured: "Env/YAML" + new: + title: "Añadir proveedor de SSO" + description: "Configura un nuevo proveedor de autenticación de inicio de sesión único" + edit: + title: "Editar proveedor de SSO" + description: "Actualizar configuración para %{label}" + create: + success: "El proveedor de SSO se ha creado correctamente." + update: + success: "El proveedor de SSO se ha actualizado correctamente." + destroy: + success: "El proveedor de SSO se ha eliminado correctamente." + confirm: "¿Estás seguro de que quieres eliminar este proveedor? Esta acción no se puede deshacer." + toggle: + success_enabled: "El proveedor de SSO se ha activado correctamente." + success_disabled: "El proveedor de SSO se ha desactivado correctamente." + confirm_enable: "¿Estás seguro de que quieres activar este proveedor?" + confirm_disable: "¿Estás seguro de que quieres desactivar este proveedor?" + form: + basic_information: "Información básica" + oauth_configuration: "Configuración OAuth/OIDC" + strategy_label: "Estrategia" + strategy_help: "La estrategia de autenticación a utilizar" + name_label: "Nombre" + name_placeholder: "ej. openid_connect, keycloak, authentik" + name_help: "Identificador único (solo minúsculas, números y guiones bajos)" + label_label: "Etiqueta" + label_placeholder: "ej. Iniciar sesión con Keycloak" + label_help: "Texto del botón mostrado a los usuarios" + icon_label: "Icono" + icon_placeholder: "ej. key, google, github" + icon_help: "Nombre del icono de Lucide (opcional)" + enabled_label: "Activar este proveedor" + enabled_help: "Los usuarios pueden iniciar sesión con este proveedor cuando está activado" + issuer_label: "Emisor (Issuer)" + issuer_placeholder: "https://accounts.google.com" + issuer_help: "URL del emisor OIDC (validará el punto de conexión .well-known/openid-configuration)" + client_id_label: "ID de cliente (Client ID)" + client_id_placeholder: "tu-id-de-cliente" + client_id_help: "ID de cliente OAuth de tu proveedor de identidad" + client_secret_label: "Secreto de cliente (Client Secret)" + client_secret_placeholder_new: "tu-secreto-de-cliente" + client_secret_placeholder_existing: "••••••••••••••••" + client_secret_help: "Secreto de cliente OAuth (encriptado en la base de datos)" + client_secret_help_existing: " - dejar en blanco para mantener el actual" + redirect_uri_label: "URI de redirección" + redirect_uri_placeholder: "https://tudominio.com/auth/openid_connect/callback" + redirect_uri_help: "URL de retorno para configurar en tu proveedor de identidad" + copy_button: "Copiar" + cancel: "Cancelar" + submit: "Guardar proveedor" + errors_title: "%{count} error impidió que se guardara este proveedor:" + provisioning_title: "Aprovisionamiento de usuarios" + default_role_label: "Rol predeterminado para nuevos usuarios" + default_role_help: "Rol asignado a los usuarios creados mediante el aprovisionamiento de cuentas SSO Just-In-Time (JIT). Por defecto es Miembro." + role_guest: "Invitado" + role_member: "Miembro" + role_admin: "Administrador" + role_super_admin: "Superadministrador" + role_mapping_title: "Mapeo de Grupos a Roles (Opcional)" + role_mapping_help: "Mapea grupos/notificaciones del IdP a roles de la aplicación. A los usuarios se les asigna el rol más alto coincidente. Deja en blanco para usar el rol predeterminado de arriba." + super_admin_groups: "Grupos de Superadministrador" + admin_groups: "Grupos de Administrador" + guest_groups: "Grupos de Invitado" + member_groups: "Grupos de Miembro" + groups_help: "Lista de nombres de grupos del IdP separados por comas. Usa * para coincidir con todos los grupos." + advanced_title: "Ajustes avanzados de OIDC" + scopes_label: "Ámbitos (Scopes) personalizados" + scopes_help: "Lista de ámbitos OIDC separados por espacios. Deja en blanco para los predeterminados (openid email profile). Añade 'groups' para recuperar las notificaciones de grupo." + prompt_label: "Solicitud de autenticación (Prompt)" + prompt_default: "Predeterminado (el IdP decide)" + prompt_login: "Forzar inicio de sesión (reautenticar)" + prompt_consent: "Forzar consentimiento (reautorizar)" + prompt_select_account: "Selección de cuenta (elegir cuenta)" + prompt_none: "Sin solicitud (autenticación silenciosa)" + prompt_help: "Controla cómo el IdP solicita información al usuario durante la autenticación." + test_connection: "Probar conexión" + saml_configuration: "Configuración SAML" + idp_metadata_url: "URL de metadatos del IdP" + idp_metadata_url_help: "URL a los metadatos SAML de tu IdP. Si se proporciona, otros ajustes de SAML se configurarán automáticamente." + manual_saml_config: "Configuración manual (si no se usa URL de metadatos)" + manual_saml_help: "Usa estos ajustes solo si tu IdP no proporciona una URL de metadatos." + idp_sso_url: "URL de SSO del IdP" + idp_slo_url: "URL de SLO del IdP (opcional)" + idp_certificate: "Certificado del IdP" + idp_certificate_help: "Certificado X.509 en formato PEM. Obligatorio si no se usa URL de metadatos." + idp_cert_fingerprint: "Huella digital del certificado (alternativa)" + name_id_format: "Formato de NameID" + name_id_email: "Dirección de correo (predeterminado)" + name_id_persistent: "Persistente" + name_id_transient: "Transitorio" + name_id_unspecified: "Sin especificar" \ No newline at end of file diff --git a/config/locales/views/admin/sso_providers/pl.yml b/config/locales/views/admin/sso_providers/pl.yml new file mode 100644 index 000000000..65bb18051 --- /dev/null +++ b/config/locales/views/admin/sso_providers/pl.yml @@ -0,0 +1,115 @@ +--- +pl: + admin: + unauthorized: Nie masz uprawnień do dostępu do tego obszaru. + sso_providers: + index: + title: Dostawcy SSO + description: Zarządzaj dostawcami uwierzytelniania jednokrotnego logowania dla swojej instancji + add_provider: Dodaj dostawcę + no_providers_title: Brak dostawców SSO + no_providers_message: Zacznij od dodania pierwszego dostawcy SSO. + note: Zmiany dostawców SSO wymagają ponownego uruchomienia serwera, aby weszły w życie. Alternatywnie włącz flagę AUTH_PROVIDERS_SOURCE=db, aby dynamicznie ładować dostawców z bazy danych. + table: + name: Nazwa + strategy: Strategia + status: Status + issuer: Wystawca + actions: Akcje + enabled: Włączony + disabled: Wyłączony + legacy_providers_title: Dostawcy skonfigurowani przez zmienne środowiskowe + legacy_providers_notice: Ci dostawcy są skonfigurowani przez zmienne środowiskowe lub YAML i nie można nimi zarządzać przez ten interfejs. Aby zarządzać nimi tutaj, przenieś ich do dostawców opartych na bazie danych, włączając AUTH_PROVIDERS_SOURCE=db i odtwarzając ich w interfejsie. + env_configured: Środowisko/YAML + new: + title: Dodaj dostawcę SSO + description: Skonfiguruj nowego dostawcę uwierzytelniania jednokrotnego logowania + edit: + title: Edytuj dostawcę SSO + description: Zaktualizuj konfigurację dla %{label} + create: + success: Dostawca SSO został pomyślnie utworzony. + update: + success: Dostawca SSO został pomyślnie zaktualizowany. + destroy: + success: Dostawca SSO został pomyślnie usunięty. + confirm: Czy na pewno chcesz usunąć tego dostawcę? Tej akcji nie można cofnąć. + toggle: + success_enabled: Dostawca SSO został pomyślnie włączony. + success_disabled: Dostawca SSO został pomyślnie wyłączony. + confirm_enable: Czy na pewno chcesz włączyć tego dostawcę? + confirm_disable: Czy na pewno chcesz wyłączyć tego dostawcę? + form: + basic_information: Podstawowe informacje + oauth_configuration: Konfiguracja OAuth/OIDC + strategy_label: Strategia + strategy_help: Strategia uwierzytelniania do użycia + name_label: Nazwa + name_placeholder: np. openid_connect, keycloak, authentik + name_help: Unikalny identyfikator (tylko małe litery, cyfry i podkreślenia) + label_label: Etykieta + label_placeholder: np. Zaloguj przez Keycloak + label_help: Tekst przycisku widoczny dla użytkowników + icon_label: Ikona + icon_placeholder: np. key, google, github + icon_help: Nazwa ikony Lucide (opcjonalne) + enabled_label: Włącz tego dostawcę + enabled_help: Użytkownicy mogą logować się przez tego dostawcę, gdy jest włączony + issuer_label: Wystawca + issuer_placeholder: https://accounts.google.com + issuer_help: URL wystawcy OIDC (waliduje endpoint .well-known/openid-configuration) + client_id_label: ID klienta + client_id_placeholder: twoj-client-id + client_id_help: ID klienta OAuth od Twojego dostawcy tożsamości + client_secret_label: Sekret klienta + client_secret_placeholder_new: twoj-client-secret + client_secret_placeholder_existing: "zapisany-sekret" + client_secret_help: Sekret klienta OAuth (zaszyfrowany w bazie danych) + client_secret_help_existing: " - pozostaw puste, aby zachować istniejący" + redirect_uri_label: URI przekierowania + redirect_uri_placeholder: https://yourdomain.com/auth/openid_connect/callback + redirect_uri_help: URL zwrotny do skonfigurowania u Twojego dostawcy tożsamości + copy_button: Kopiuj + cancel: Anuluj + submit: Zapisz dostawcę + errors_title: "%{count} błąd uniemożliwia zapisanie tego dostawcy:" + provisioning_title: Udostępnianie użytkowników + default_role_label: Domyślna rola dla nowych użytkowników + default_role_help: "Rola przypisywana użytkownikom tworzonymi przez SSO just-in-time (JIT). Domyślnie: Członek." + role_guest: Gość + role_member: Członek + role_admin: Administrator + role_super_admin: Superadministrator + role_mapping_title: Mapowanie grup na role (opcjonalne) + role_mapping_help: Mapuj grupy/claims IdP na role aplikacji. Użytkownicy otrzymują najwyższą pasującą rolę. Pozostaw puste, aby używać domyślnej roli powyżej. + super_admin_groups: Grupy Super Admina + admin_groups: Grupy Admina + guest_groups: Grupy Gościa + member_groups: Grupy Członka + groups_help: Lista nazw grup IdP oddzielona przecinkami. Użyj *, aby pasować do wszystkich grup. + advanced_title: Zaawansowane ustawienia OIDC + scopes_label: Własne zakresy + scopes_help: Lista zakresów OIDC oddzielona spacjami. Pozostaw puste dla wartości domyślnych (openid email profile). Dodaj 'groups', aby pobrać claims grup. + prompt_label: Monit uwierzytelniania + prompt_default: Domyślny (decyduje IdP) + prompt_login: Wymuś logowanie (ponowne uwierzytelnienie) + prompt_consent: Wymuś zgodę (ponowna autoryzacja) + prompt_select_account: Wybór konta (wybierz konto) + prompt_none: Bez monitu (uwierzytelnienie ciche) + prompt_help: Określa, w jaki sposób IdP wyświetla monity użytkownikowi podczas uwierzytelniania. + test_connection: Testuj połączenie + saml_configuration: Konfiguracja SAML + idp_metadata_url: URL metadanych IdP + idp_metadata_url_help: URL do metadanych SAML Twojego IdP. Jeśli podany, inne ustawienia SAML zostaną skonfigurowane automatycznie. + manual_saml_config: Konfiguracja ręczna (jeśli nie używasz adresu URL metadanych) + manual_saml_help: Używaj tych ustawień tylko wtedy, gdy Twój IdP nie udostępnia adresu URL metadanych. + idp_sso_url: URL SSO IdP + idp_slo_url: IdP SLO URL (opcjonalne) + idp_certificate: Certyfikat IdP + idp_certificate_help: Certyfikat X.509 w formacie PEM. Wymagany, jeśli nie używasz adresu URL metadanych. + idp_cert_fingerprint: Odcisk cyfrowy certyfikatu (alternatywa) + name_id_format: Format identyfikatora nazwy + name_id_email: Adres e-mail (domyślny) + name_id_persistent: Trwały + name_id_transient: Przejściowy + name_id_unspecified: Nieokreślony diff --git a/config/locales/views/admin/users/de.yml b/config/locales/views/admin/users/de.yml new file mode 100644 index 000000000..2eda1c6b9 --- /dev/null +++ b/config/locales/views/admin/users/de.yml @@ -0,0 +1,45 @@ +--- +de: + admin: + users: + index: + title: "Benutzerverwaltung" + description: "Benutzerrollen für Ihre Instanz verwalten. Super-Admins haben Zugriff auf SSO-Provider und Benutzerverwaltung." + section_title: "Benutzer" + you: "(Sie)" + trial_ends_at: "Testversion endet" + not_available: "k. A." + no_users: "Keine Benutzer gefunden." + filters: + role: "Rolle" + role_all: "Alle Rollen" + trial_status: "Teststatus" + trial_all: "Alle" + trial_expiring_soon: "Läuft in 7 Tagen ab" + trial_trialing: "In Testphase" + submit: "Filtern" + summary: + trials_expiring_7_days: "Testversionen laufen in den nächsten 7 Tagen ab" + table: + user: "Benutzer" + trial_ends_at: "Testversion endet" + family_accounts: "Familienkonten" + family_transactions: "Familienbuchungen" + last_login: "Letzte Anmeldung" + session_count: "Anzahl Sitzungen" + never: "Nie" + role: "Rolle" + role_descriptions_title: "Rollenbeschreibungen" + roles: + guest: "Gast" + member: "Mitglied" + admin: "Admin" + super_admin: "Super-Admin" + role_descriptions: + guest: "Assistenten-orientierte Nutzung mit eingeschränkten Rechten für Einführungsabläufe." + member: "Standard-Zugriff. Kann eigene Konten, Buchungen und Einstellungen verwalten." + admin: "Familien-Administrator. Zugriff auf erweiterte Einstellungen wie API-Schlüssel, Importe und KI-Prompts." + super_admin: "Instanz-Administrator. Kann SSO-Provider, Benutzerrollen verwalten und Benutzer für Support vertreten." + update: + success: "Benutzerrolle wurde erfolgreich aktualisiert." + failure: "Benutzerrolle konnte nicht aktualisiert werden." diff --git a/config/locales/views/admin/users/en.yml b/config/locales/views/admin/users/en.yml index 7eb7102a7..c14a243d5 100644 --- a/config/locales/views/admin/users/en.yml +++ b/config/locales/views/admin/users/en.yml @@ -5,11 +5,14 @@ en: index: title: "User Management" description: "Manage user roles for your instance. Super admins can access SSO provider settings and user management." - section_title: "Users" + section_title: "Families / Groups" you: "(You)" trial_ends_at: "Trial ends" not_available: "n/a" no_users: "No users found." + unnamed_family: "Unnamed Family/Group" + no_subscription: "No subscription" + family_summary: "%{members} members · %{accounts} accounts · %{transactions} transactions" filters: role: "Role" role_all: "All roles" @@ -40,6 +43,11 @@ en: member: "Basic user access. Can manage their own accounts, transactions, and settings." admin: "Family administrator. Can access advanced settings like API keys, imports, and AI prompts." super_admin: "Instance administrator. Can manage SSO providers, user roles, and impersonate users for support." + invitations: + pending_label: "Invited (pending)" + expires: "Expires %{date}" + delete: "Delete" + delete_all: "Delete All" update: success: "User role updated successfully." failure: "Failed to update user role." diff --git a/config/locales/views/admin/users/es.yml b/config/locales/views/admin/users/es.yml new file mode 100644 index 000000000..d2cad061f --- /dev/null +++ b/config/locales/views/admin/users/es.yml @@ -0,0 +1,45 @@ +--- +es: + admin: + users: + index: + title: "Gestión de usuarios" + description: "Gestiona los roles de usuario para tu instancia. Los superadministradores pueden acceder a la configuración del proveedor de SSO y a la gestión de usuarios." + section_title: "Usuarios" + you: "(Tú)" + trial_ends_at: "La prueba finaliza" + not_available: "n/a" + no_users: "No se han encontrado usuarios." + filters: + role: "Rol" + role_all: "Todos los roles" + trial_status: "Estado de la prueba" + trial_all: "Todos" + trial_expiring_soon: "Caduca en 7 días" + trial_trialing: "En periodo de prueba" + submit: "Filtrar" + summary: + trials_expiring_7_days: "Pruebas que caducan en los próximos 7 días" + table: + user: "Usuario" + trial_ends_at: "La prueba finaliza" + family_accounts: "Cuentas familiares" + family_transactions: "Transacciones familiares" + last_login: "Último inicio de sesión" + session_count: "Número de sesiones" + never: "Nunca" + role: "Rol" + role_descriptions_title: "Descripción de los roles" + roles: + guest: "Invitado" + member: "Miembro" + admin: "Administrador" + super_admin: "Superadministrador" + role_descriptions: + guest: "Experiencia centrada en el asistente con permisos restringidos intencionadamente para flujos de introducción." + member: "Acceso de usuario básico. Pueden gestionar sus propias cuentas, transacciones y ajustes." + admin: "Administrador de la familia. Puede acceder a ajustes avanzados como claves API, importaciones e instrucciones de IA." + super_admin: "Administrador de la instancia. Puede gestionar proveedores de SSO, roles de usuario y suplantar a usuarios para soporte." + update: + success: "Rol de usuario actualizado correctamente." + failure: "Error al actualizar el rol de usuario." \ No newline at end of file diff --git a/config/locales/views/admin/users/pl.yml b/config/locales/views/admin/users/pl.yml new file mode 100644 index 000000000..f618ebcc3 --- /dev/null +++ b/config/locales/views/admin/users/pl.yml @@ -0,0 +1,53 @@ +--- +pl: + admin: + users: + index: + title: Zarządzanie użytkownikami + description: Zarządzaj rolami użytkowników w swojej instancji. Superadministratorzy mają dostęp do ustawień dostawców SSO i zarządzania użytkownikami. + section_title: Rodziny / Grupy + you: "(Ty)" + trial_ends_at: Koniec okresu próbnego + not_available: brak danych + no_users: Nie znaleziono użytkowników. + unnamed_family: Nienazwana rodzina/grupa + no_subscription: Brak subskrypcji + family_summary: "%{members} członków · %{accounts} kont · %{transactions} transakcji" + filters: + role: Rola + role_all: Wszystkie role + trial_status: Status okresu próbnego + trial_all: Wszystkie + trial_expiring_soon: Wygasa za 7 dni + trial_trialing: W okresie próbnym + submit: Filtruj + summary: + trials_expiring_7_days: Okresy próbne wygasające w ciągu 7 dni + table: + user: Użytkownik + trial_ends_at: Koniec okresu próbnego + family_accounts: Konta rodziny + family_transactions: Transakcje rodziny + last_login: Ostatnie logowanie + session_count: Liczba sesji + never: Nigdy + role: Rola + role_descriptions_title: Opisy ról + roles: + guest: Gość + member: Członek + admin: Administrator + super_admin: Superadministrator + role_descriptions: + guest: Tryb skupiony na Asystencie z celowo ograniczonymi uprawnieniami dla przepływów wprowadzających. + member: Podstawowy dostęp użytkownika. Może zarządzać własnymi kontami, transakcjami i ustawieniami. + admin: Administrator rodziny. Ma dostęp do ustawień zaawansowanych, takich jak klucze API, importy i prompty AI. + super_admin: Administrator instancji. Może zarządzać dostawcami SSO, rolami użytkowników i wcielać się w użytkowników na potrzeby wsparcia. + invitations: + pending_label: Zaproszony (oczekuje) + expires: Wygasa %{date} + delete: Usuń + delete_all: Usuń wszystkie + update: + success: Rola użytkownika została pomyślnie zaktualizowana. + failure: Nie udało się zaktualizować roli użytkownika. diff --git a/config/locales/views/admin/users/pt-BR.yml b/config/locales/views/admin/users/pt-BR.yml new file mode 100644 index 000000000..591a60b6f --- /dev/null +++ b/config/locales/views/admin/users/pt-BR.yml @@ -0,0 +1,53 @@ +--- +pt-BR: + admin: + users: + index: + title: "Gestão de Usuários" + description: "Gerencie as funções de usuário da sua instância. Os superadministradores podem acessar as configurações do provedor SSO e o gerenciamento de usuários." + section_title: "Famílias / Grupos" + you: "(Você)" + trial_ends_at: "Fim do teste" + not_available: "n/a" + no_users: "Nenhum usuário encontrado." + unnamed_family: "Família/Grupo sem nome" + no_subscription: "Sem assinatura" + family_summary: "%{members} membros · %{accounts} contas · %{transactions} transações" + filters: + role: "Cargo" + role_all: "Todas as funções" + trial_status: "Status do teste" + trial_all: "Todos" + trial_expiring_soon: "Expira em 7 dias" + trial_trialing: "Em teste" + submit: "Filtro" + summary: + trials_expiring_7_days: "Testes expiram nos próximos 7 dias" + table: + user: "Usuário" + trial_ends_at: "Fim do teste" + family_accounts: "Contas familiares" + family_transactions: "Transações familiares" + last_login: "Último login" + session_count: "Contagem de sessões" + never: "Nunca" + role: "Cargo" + role_descriptions_title: "Descrições de Cargos" + roles: + guest: "Convidado" + member: "Membro" + admin: "Admin" + super_admin: "Super Admin" + role_descriptions: + guest: "Experiência com foco no assistente, com permissões intencionalmente restritas para fluxos de trabalho introdutórios." + member: "Acesso básico de usuário. Pode gerenciar suas próprias contas, transações e configurações." + admin: "Administrador familiar. Pode acessar configurações avançadas como chaves de API, importações e avisos de IA." + super_admin: "Administrador de instância. Pode gerenciar provedores de SSO, funções de usuário e representar usuários para suporte." + invitations: + pending_label: "Convidado (pendente)" + expires: "Expira em %{date}" + delete: "Excluir" + delete_all: "Excluir todos" + update: + success: "Função de usuário atualizada com sucesso." + failure: "Falha ao atualizar a função do usuário." diff --git a/config/locales/views/application/pl.yml b/config/locales/views/application/pl.yml new file mode 100644 index 000000000..80b45673a --- /dev/null +++ b/config/locales/views/application/pl.yml @@ -0,0 +1,10 @@ +--- +pl: + number: + currency: + format: + delimiter: " " + format: "%n %u" + precision: 2 + separator: "," + unit: "zł" diff --git a/config/locales/views/binance_items/en.yml b/config/locales/views/binance_items/en.yml new file mode 100644 index 000000000..ae3bd298a --- /dev/null +++ b/config/locales/views/binance_items/en.yml @@ -0,0 +1,75 @@ +--- +en: + binance_items: + create: + default_name: Binance + success: Successfully connected to Binance! Your account is being synced. + update: + success: Successfully updated Binance configuration. + destroy: + success: Scheduled Binance connection for deletion. + setup_accounts: + title: Import Binance Account + subtitle: Select which portfolios to track + instructions: Select the Binance portfolios you want to import. Only portfolios with balances are shown. + no_accounts: All accounts have been imported. + accounts_count: + one: "%{count} account available" + other: "%{count} accounts available" + select_all: Select all + import_selected: Import Selected + cancel: Cancel + creating: Importing... + complete_account_setup: + success: + one: "Imported %{count} account" + other: "Imported %{count} accounts" + none_selected: No accounts selected + no_accounts: No accounts to import + binance_item: + provider_name: Binance + syncing: Syncing... + reconnect: Credentials need updating + deletion_in_progress: Deleting... + sync_status: + no_accounts: No accounts found + all_synced: + one: "%{count} account synced" + other: "%{count} accounts synced" + partial_sync: "%{linked_count} synced, %{unlinked_count} need setup" + status: "Last synced %{timestamp} ago" + status_with_summary: "Last synced %{timestamp} ago - %{summary}" + status_never: Never synced + update_credentials: Update credentials + delete: Delete + no_accounts_title: No accounts found + no_accounts_message: Your Binance portfolio will appear here after syncing. + setup_needed: Account ready to import + setup_description: Select which Binance portfolios you want to track. + setup_action: Import Account + import_accounts_menu: Import Account + stale_rate_warning: "Balance is approximate — the exact exchange rate for %{date} was unavailable. Will update on next sync." + select_existing_account: + title: Link Binance Account + no_accounts_found: No Binance accounts found. + wait_for_sync: Wait for Binance to finish syncing + check_provider_health: Check that your Binance API credentials are valid + currently_linked_to: "Currently linked to: %{account_name}" + link: Link + cancel: Cancel + link_existing_account: + success: Successfully linked to Binance account + errors: + only_manual: Only manual accounts can be linked to Binance + invalid_binance_account: Invalid Binance account + binance_item: + syncer: + checking_credentials: Checking credentials... + credentials_invalid: Invalid API credentials. Please check your API key and secret. + importing_accounts: Importing accounts from Binance... + checking_configuration: Checking account configuration... + accounts_need_setup: + one: "%{count} account needs setup" + other: "%{count} accounts need setup" + processing_accounts: Processing account data... + calculating_balances: Calculating balances... diff --git a/config/locales/views/bonds/pl.yml b/config/locales/views/bonds/pl.yml new file mode 100644 index 000000000..c4af32682 --- /dev/null +++ b/config/locales/views/bonds/pl.yml @@ -0,0 +1,153 @@ +--- +pl: + bonds: + edit: + edit: Edytuj %{account} + form: + initial_balance: Początkowe saldo obligacji + tax_wrapper: Otoczka podatkowa + auto_buy_new_issues: Automatycznie kupuj nowe emisje obligacji + auto_buy_new_issues_hint: Po rozliczeniu zapadalności automatycznie kup kolejną emisję z dostępnych środków dla kont IKE/IKZE. + subtypes: + eod: + short: EOD + long: 10-letnia obligacja oszczędnościowa Skarbu Państwa + rod: + short: ROD + long: 12-letnia rodzinna obligacja oszczędnościowa + other_bond: + short: Inna + long: Inna obligacja + new: + title: Wprowadź dane obligacji + tabs: + positions: + positions: Pozycje obligacji + new_activity: Nowa aktywność + name: Nazwa + weight: Udział + rate: Oprocentowanie + holdings: Stan + maturity: Zapadalność + total_return: Łączny zwrot + no_purchases: Nie dodano jeszcze żadnych zakupów. + closed: + closed: Zamknięte partie obligacji + name: Nazwa + weight: Udział + rate: Oprocentowanie + holdings: Stan + maturity: Zapadalność + total_return: Łączny zwrot + no_closed_lots: Brak zamkniętych partii obligacji. + closed_lots: Zamknięte partie obligacji + closed_lot_meta: "Zakupiono %{purchased}, zamknięto %{closed}" + settled_net: Rozliczono netto + cash_holding: + cash_position: Gotówka z obligacji + purchase_holding: + unknown: Nieznane + update_needed: Wymagana aktualizacja + purchased: "Zakupiono %{date}" + term_months: + one: "%{count} miesiąc" + few: "%{count} miesiące" + many: "%{count} miesięcy" + other: "%{count} miesiąca" + principal_term: "Kapitał, %{term}" + bond_meta: "%{rate_type} / %{coupon}" + inflation_meta_gus: "%{inflation} inflacji + %{margin} marży (GUS SDP %{indicator})" + inflation_meta_manual: "%{inflation} inflacji + %{margin} marży (ręcznie)" + first_period_fixed_rate: Stałe oprocentowanie pierwszego okresu + maturity: "Zapada %{date}" + maturity_label: Data zapadalności + since_purchase: Od zakupu + projected_to_maturity: Prognoza do zapadalności + pending_review: Oczekiwanie na zaktualizowane stawki emisji + edit: Edytuj + remove: Usuń + confirm_remove: Usunąć ten zakup? + + bond_lots: + not_bond_account: "To konto nie jest kontem obligacji." + new: + title: Dodaj zakup dla %{account} + edit: + title: Edytuj zakup dla %{account} + show: + settings: Ustawienia + history: Historia + no_history: Brak historii kapitalizacji. + overview_principal: Kapitał + overview_settlement: Kwota rozliczenia + overview_maturity: Data zapadalności + overview_closed_on: Zamknięto + history_period: "Okres %{period}: %{start} - %{end}" + history_balance: "Saldo %{opening} -> %{closing}" + history_interest: "Odsetki %{interest} przy %{rate}" + history_capitalized: Skapitalizowano + history_partial: Okres częściowy + history_inflation_gus: "Użyta inflacja: %{inflation} + %{margin} marży (GUS SDP %{indicator}, odn. %{reference})" + history_inflation_manual: "Użyta inflacja: %{inflation} + %{margin} marży (założenie ręczne)" + history_inflation_first_period: "Stała stopa pierwszego okresu" + unknown: Nieznane + purchased: "Zakupiono %{date}" + delete_title: Usuń zakup + delete_subtitle: Usuń ten zakup i powiązany wpis aktywności. + delete: Usuń + delete_confirm: Usunąć ten zakup? + create: + success: Dodano zakup obligacji + update: + success: Zaktualizowano zakup obligacji + destroy: + success: Usunięto zakup obligacji + form: + purchased_on: Data zakupu + issue_date: Data emisji + amount: Kwota kapitału + units: Jednostki + nominal_per_unit: Nominał na jednostkę + term_months: Okres (miesiące) + subtype: Typ obligacji + rate_type: Typ oprocentowania + coupon_frequency: Częstotliwość kuponu + interest_rate: Oprocentowanie + interest_rate_placeholder: "4.25" + first_period_rate: Oprocentowanie pierwszego okresu (%) + inflation_margin: Marża inflacyjna (%) + auto_fetch_inflation: Pobieraj inflację automatycznie z GUS + auto_fetch_disabled_hint: Automatyczny import CPI jest globalnie wyłączony w ustawieniach self-hosting. Wprowadź inflację ręcznie. + inflation_rate_assumption: Założenie CPI (%) + cpi_lag_months: Opóźnienie CPI (miesiące) + early_redemption_fee: Opłata za wcześniejszy wykup + auto_close_on_maturity: Automatycznie zamknij w dniu zapadalności + auto_close_on_maturity_hint: Automatycznie rozlicz tę partię w dniu zapadalności i przeksięguj środki na gotówkę konta. + tax_strategy: Obsługa podatku przy zapadalności + tax_rate: Stawka podatku (%) + tax_strategies: + standard: Podatek standardowy + reduced: Podatek obniżony + exempt: Zwolnione z podatku (IKE/IKZE) + rate_types: + fixed: Stałe + variable: Zmienne + coupon_frequencies: + monthly: Miesięcznie + quarterly: Kwartalnie + semi_annual: Półrocznie + annual: Rocznie + at_maturity: W terminie zapadalności + submit: Dodaj zakup + update: Zaktualizuj zakup + activity: + purchase_name: "Zakup obligacji: %{subtype}" + maturity_settlement_name: "Rozliczenie zapadalności obligacji: %{subtype}" + maturity_settlement_notes_with_tax: "Kwota zakupu: %{purchase_amount}\nŁączne odsetki: %{interest_amount}\nPotrącony podatek: %{tax_withheld_amount}" + maturity_settlement_notes_without_tax: "Kwota zakupu: %{purchase_amount}\nŁączne odsetki: %{interest_amount}\nPotrącony podatek: brak" + + closed_purchase_holding: + closed_meta: "Zakupiono %{purchased}, zamknięto %{closed}" + closed_rate_meta: "%{periods} okresów kapitalizacji" + settled_net: "Rozliczono netto %{net}" + history_meta: "Odsetki %{interest}, podatek %{tax}" diff --git a/config/locales/views/budgets/de.yml b/config/locales/views/budgets/de.yml new file mode 100644 index 000000000..00ac0252b --- /dev/null +++ b/config/locales/views/budgets/de.yml @@ -0,0 +1,10 @@ +--- +de: + budgets: + name: + custom_range: "%{start} - %{end_date}" + month_year: "%{month}" + show: + tabs: + actual: Ist + budgeted: Budgetiert diff --git a/config/locales/views/budgets/en.yml b/config/locales/views/budgets/en.yml index 6f98a5686..c727dc37c 100644 --- a/config/locales/views/budgets/en.yml +++ b/config/locales/views/budgets/en.yml @@ -8,3 +8,12 @@ en: tabs: actual: Actual budgeted: Budgeted + copy_previous_prompt: + title: "Set up your budget" + description: "You can copy your budget from %{source_name} or start fresh." + copy_button: "Copy from %{source_name}" + fresh_button: "Start fresh" + copy_previous: + success: "Budget copied from %{source_name}" + no_source: "No previous budget found to copy from" + already_initialized: "This budget has already been set up" diff --git a/config/locales/views/budgets/es.yml b/config/locales/views/budgets/es.yml new file mode 100644 index 000000000..87bc1649b --- /dev/null +++ b/config/locales/views/budgets/es.yml @@ -0,0 +1,10 @@ +--- +es: + budgets: + name: + custom_range: "%{start} - %{end_date}" + month_year: "%{month}" + show: + tabs: + actual: Real + budgeted: Presupuestado \ No newline at end of file diff --git a/config/locales/views/budgets/pl.yml b/config/locales/views/budgets/pl.yml new file mode 100644 index 000000000..ab3dc9828 --- /dev/null +++ b/config/locales/views/budgets/pl.yml @@ -0,0 +1,19 @@ +--- +pl: + budgets: + name: + custom_range: "%{start} do %{end_date}" + month_year: "%{month}" + show: + tabs: + actual: Rzeczywiste + budgeted: Zaplanowane + copy_previous_prompt: + title: "Skonfiguruj swój budżet" + description: "Możesz skopiować budżet z %{source_name} lub zacząć od zera." + copy_button: "Kopiuj z %{source_name}" + fresh_button: "Zacznij od zera" + copy_previous: + success: "Skopiowano budżet z %{source_name}" + no_source: "Nie znaleziono poprzedniego budżetu do skopiowania" + already_initialized: "Ten budżet został już skonfigurowany" diff --git a/config/locales/views/budgets/pt-BR.yml b/config/locales/views/budgets/pt-BR.yml new file mode 100644 index 000000000..d0c4474a1 --- /dev/null +++ b/config/locales/views/budgets/pt-BR.yml @@ -0,0 +1,19 @@ +--- +pt-BR: + budgets: + name: + custom_range: "%{start} - %{end_date}" + month_year: "%{month}" + show: + tabs: + actual: Atual + budgeted: Orçamento + copy_previous_prompt: + title: "Defina seu orçamento" + description: "Você pode copiar seu orçamento de %{source_name} ou começar do zero." + copy_button: "Copiar de %{source_name}" + fresh_button: "Comece do zero" + copy_previous: + success: "Orçamento copiado de %{source_name}" + no_source: "Não foi encontrado nenhum orçamento anterior para servir de base." + already_initialized: "Este orçamento já foi definido." diff --git a/config/locales/views/categories/pl.yml b/config/locales/views/categories/pl.yml new file mode 100644 index 000000000..85720c432 --- /dev/null +++ b/config/locales/views/categories/pl.yml @@ -0,0 +1,34 @@ +--- +pl: + categories: + bootstrap: + success: Domyślne kategorie zostały pomyślnie utworzone + category: + delete: Usuń kategorię + edit: Edytuj kategorię + create: + success: Kategoria została pomyślnie utworzona + destroy: + success: Kategoria została pomyślnie usunięta + edit: + edit: Edytuj kategorię + form: + placeholder: Nazwa kategorii + index: + bootstrap: Użyj domyślnych (zalecane) + categories: Kategorie + categories_expenses: Kategorie wydatków + categories_incomes: Kategorie przychodów + empty: Nie znaleziono kategorii + new: Nowa kategoria + menu: + loading: Ładowanie... + new: + new_category: Nowa kategoria + update: + success: Kategoria została pomyślnie zaktualizowana + category: + dropdowns: + show: + bootstrap: Wygeneruj domyślne kategorie + empty: Nie znaleziono kategorii diff --git a/config/locales/views/category/deletions/pl.yml b/config/locales/views/category/deletions/pl.yml new file mode 100644 index 000000000..c4561efc3 --- /dev/null +++ b/config/locales/views/category/deletions/pl.yml @@ -0,0 +1,13 @@ +--- +pl: + category: + deletions: + create: + success: Kategoria transakcji została usunięta + new: + category: Kategoria + delete_and_leave_uncategorized: Usuń "%{category_name}" i pozostaw bez kategorii + delete_and_recategorize: Usuń "%{category_name}" i przypisz nową kategorię + delete_category: Usunąć kategorię? + explanation: Po usunięciu tej kategorii każda transakcja przypisana do "%{category_name}" będzie bez kategorii. Zamiast pozostawiać je bez kategorii, możesz poniżej przypisać nową kategorię. + replacement_category_prompt: Wybierz kategorię diff --git a/config/locales/views/category/dropdowns/pl.yml b/config/locales/views/category/dropdowns/pl.yml new file mode 100644 index 000000000..74380bb48 --- /dev/null +++ b/config/locales/views/category/dropdowns/pl.yml @@ -0,0 +1,11 @@ +--- +pl: + category: + dropdowns: + row: + delete: Usuń kategorię + edit: Edytuj kategorię + show: + clear: Wyczyść kategorię + no_categories: Nie znaleziono kategorii + search_placeholder: Szukaj diff --git a/config/locales/views/chats/de.yml b/config/locales/views/chats/de.yml new file mode 100644 index 000000000..2d38fb9aa --- /dev/null +++ b/config/locales/views/chats/de.yml @@ -0,0 +1,5 @@ +--- +de: + chats: + demo_banner_title: "Demo-Modus aktiv" + demo_banner_message: "Sie nutzen ein Open-Weight Qwen3-LLM mit Credits von Cloudflare Workers AI. Die Ergebnisse können variieren, da die Codebasis hauptsächlich mit `gpt-4.1` getestet wurde – Ihre Tokens werden jedoch nicht anderswo zum Training verwendet! 🤖" diff --git a/config/locales/views/chats/es.yml b/config/locales/views/chats/es.yml new file mode 100644 index 000000000..d1d8ed83f --- /dev/null +++ b/config/locales/views/chats/es.yml @@ -0,0 +1,5 @@ +--- +es: + chats: + demo_banner_title: "Modo de demostración activo" + demo_banner_message: "Estás utilizando un LLM Qwen3 de pesos abiertos con créditos proporcionados por Cloudflare Workers AI. Los resultados pueden variar, ya que la base de código se probó principalmente con `gpt-4.1`, ¡pero tus tokens no se enviarán a ningún otro lugar para ser entrenados! 🤖" \ No newline at end of file diff --git a/config/locales/views/chats/pl.yml b/config/locales/views/chats/pl.yml new file mode 100644 index 000000000..588542fce --- /dev/null +++ b/config/locales/views/chats/pl.yml @@ -0,0 +1,5 @@ +--- +pl: + chats: + demo_banner_title: "Aktywny tryb demo" + demo_banner_message: "Używasz modelu open-weights Qwen3 LLM z kredytami udostępnionymi przez Cloudflare Workers AI. Wyniki mogą się różnić, ponieważ kod aplikacji był głównie testowany na `gpt-4.1`, ale Twoje tokeny nie są nigdzie dalej wykorzystywane do trenowania! 🤖" diff --git a/config/locales/views/coinbase_items/de.yml b/config/locales/views/coinbase_items/de.yml new file mode 100644 index 000000000..a7300e8d8 --- /dev/null +++ b/config/locales/views/coinbase_items/de.yml @@ -0,0 +1,78 @@ +--- +de: + coinbase_items: + create: + default_name: Coinbase + success: Mit Coinbase verbunden! Ihre Konten werden synchronisiert. + update: + success: Coinbase-Konfiguration wurde erfolgreich aktualisiert. + destroy: + success: Coinbase-Verbindung wurde zur Löschung vorgemerkt. + setup_accounts: + title: Coinbase-Wallets importieren + subtitle: Wählen Sie die zu verfolgenden Wallets + instructions: Wählen Sie die Wallets zum Import. Nicht ausgewählte Wallets bleiben verfügbar für einen späteren Import. + no_accounts: Alle Wallets wurden bereits importiert. + accounts_count: + one: "%{count} Wallet verfügbar" + other: "%{count} Wallets verfügbar" + select_all: Alle auswählen + import_selected: Ausgewählte importieren + cancel: Abbrechen + creating: Importiere... + complete_account_setup: + success: + one: "%{count} Wallet importiert" + other: "%{count} Wallets importiert" + none_selected: Keine Wallets ausgewählt + no_accounts: Keine Wallets zum Import + coinbase_item: + provider_name: Coinbase + syncing: Synchronisiere... + reconnect: Zugangsdaten müssen aktualisiert werden + deletion_in_progress: Wird gelöscht... + sync_status: + no_accounts: Keine Konten gefunden + all_synced: + one: "%{count} Konto synchronisiert" + other: "%{count} Konten synchronisiert" + partial_sync: "%{linked_count} synchronisiert, %{unlinked_count} müssen eingerichtet werden" + status: "Zuletzt synchronisiert vor %{timestamp}" + status_with_summary: "Zuletzt synchronisiert vor %{timestamp} – %{summary}" + status_never: Noch nie synchronisiert + update_credentials: Zugangsdaten aktualisieren + delete: Löschen + no_accounts_title: Keine Konten gefunden + no_accounts_message: Ihre Coinbase-Wallets erscheinen hier nach dem Sync. + setup_needed: Wallets bereit zum Import + setup_description: Wählen Sie die Coinbase-Wallets, die Sie verfolgen möchten. + setup_action: Wallets importieren + import_wallets_menu: Wallets importieren + more_wallets_available: + one: "%{count} weiteres Wallet zum Import verfügbar" + other: "%{count} weitere Wallets zum Import verfügbar" + select_existing_account: + title: Coinbase-Konto verknüpfen + no_accounts_found: Keine Coinbase-Konten gefunden. + wait_for_sync: Warten Sie, bis Coinbase die Synchronisation abgeschlossen hat + check_provider_health: Prüfen Sie, ob Ihre Coinbase-API-Zugangsdaten gültig sind + balance: Saldo + currently_linked_to: "Aktuell verknüpft mit: %{account_name}" + link: Verknüpfen + cancel: Abbrechen + link_existing_account: + success: Erfolgreich mit Coinbase-Konto verknüpft + errors: + only_manual: Nur manuelle Konten können mit Coinbase verknüpft werden + invalid_coinbase_account: Ungültiges Coinbase-Konto + coinbase_item: + syncer: + checking_credentials: Zugangsdaten werden geprüft... + credentials_invalid: Ungültige API-Zugangsdaten. Bitte API-Key und Secret prüfen. + importing_accounts: Konten werden von Coinbase importiert... + checking_configuration: Kontokonfiguration wird geprüft... + accounts_need_setup: + one: "%{count} Konto muss eingerichtet werden" + other: "%{count} Konten müssen eingerichtet werden" + processing_accounts: Kontodaten werden verarbeitet... + calculating_balances: Salden werden berechnet... diff --git a/config/locales/views/coinbase_items/es.yml b/config/locales/views/coinbase_items/es.yml new file mode 100644 index 000000000..4efeeb0eb --- /dev/null +++ b/config/locales/views/coinbase_items/es.yml @@ -0,0 +1,78 @@ +--- +es: + coinbase_items: + create: + default_name: Coinbase + success: ¡Conexión con Coinbase establecida con éxito! Tus cuentas se están sincronizando. + update: + success: Configuración de Coinbase actualizada correctamente. + destroy: + success: Conexión de Coinbase programada para su eliminación. + setup_accounts: + title: Importar carteras de Coinbase + subtitle: Selecciona qué carteras quieres seguir + instructions: Selecciona las carteras que quieres importar. Las carteras no seleccionadas seguirán estando disponibles por si quieres añadirlas más tarde. + no_accounts: Se han importado todas las carteras. + accounts_count: + one: "%{count} cartera disponible" + other: "%{count} carteras disponibles" + select_all: Seleccionar todas + import_selected: Importar seleccionadas + cancel: Cancelar + creating: Importando... + complete_account_setup: + success: + one: "Se ha importado %{count} cartera" + other: "Se han importado %{count} carteras" + none_selected: No se ha seleccionado ninguna cartera + no_accounts: No hay carteras para importar + coinbase_item: + provider_name: Coinbase + syncing: Sincronizando... + reconnect: Es necesario actualizar las credenciales + deletion_in_progress: Eliminando... + sync_status: + no_accounts: No se han encontrado cuentas + all_synced: + one: "%{count} cuenta sincronizada" + other: "%{count} cuentas sincronizadas" + partial_sync: "%{linked_count} sincronizadas, %{unlinked_count} necesitan configuración" + status: "Sincronizado hace %{timestamp}" + status_with_summary: "Sincronizado hace %{timestamp} - %{summary}" + status_never: Nunca sincronizado + update_credentials: Actualizar credenciales + delete: Eliminar + no_accounts_title: No se han encontrado cuentas + no_accounts_message: Tus carteras de Coinbase aparecerán aquí después de la sincronización. + setup_needed: Carteras listas para importar + setup_description: Selecciona qué carteras de Coinbase quieres seguir. + setup_action: Importar carteras + import_wallets_menu: Importar carteras + more_wallets_available: + one: "%{count} cartera más disponible para importar" + other: "%{count} carteras más disponibles para importar" + select_existing_account: + title: Vincular cuenta de Coinbase + no_accounts_found: No se han encontrado cuentas de Coinbase. + wait_for_sync: Espera a que Coinbase termine de sincronizar + check_provider_health: Comprueba que tus credenciales de la API de Coinbase sean válidas + balance: Saldo + currently_linked_to: "Vinculada actualmente a: %{account_name}" + link: Vincular + cancel: Cancelar + link_existing_account: + success: Vinculado correctamente a la cuenta de Coinbase + errors: + only_manual: Solo las cuentas manuales pueden vincularse a Coinbase + invalid_coinbase_account: Cuenta de Coinbase no válida + coinbase_item: + syncer: + checking_credentials: Comprobando credenciales... + credentials_invalid: Credenciales de API no válidas. Por favor, comprueba tu clave API y el secreto. + importing_accounts: Importando cuentas desde Coinbase... + checking_configuration: Comprobando la configuración de la cuenta... + accounts_need_setup: + one: "%{count} cuenta necesita configuración" + other: "%{count} cuentas necesitan configuración" + processing_accounts: Procesando datos de la cuenta... + calculating_balances: Calculando saldos. \ No newline at end of file diff --git a/config/locales/views/coinbase_items/pl.yml b/config/locales/views/coinbase_items/pl.yml new file mode 100644 index 000000000..644cff0ca --- /dev/null +++ b/config/locales/views/coinbase_items/pl.yml @@ -0,0 +1,88 @@ +--- +pl: + coinbase_items: + create: + default_name: Coinbase + success: Pomyślnie połączono z Coinbase! Twoje konta są synchronizowane. + update: + success: Pomyślnie zaktualizowano konfigurację Coinbase. + destroy: + success: Zaplanowano usunięcie połączenia Coinbase. + setup_accounts: + title: Importuj portfele Coinbase + subtitle: Wybierz portfele do śledzenia + instructions: Wybierz portfele, które chcesz zaimportować. Niewybrane portfele pozostaną dostępne, jeśli zechcesz dodać je później. + no_accounts: Wszystkie portfele zostały zaimportowane. + accounts_count: + one: "Dostępny %{count} portfel" + few: "Dostępne %{count} portfele" + many: "Dostępnych %{count} portfeli" + other: "Dostępne %{count} portfeli" + select_all: Wybierz wszystkie + import_selected: Importuj wybrane + cancel: Anuluj + creating: Importowanie... + complete_account_setup: + success: + one: Zaimportowano %{count} portfel + few: Zaimportowano %{count} portfele + many: Zaimportowano %{count} portfeli + other: Zaimportowano %{count} portfeli + none_selected: Nie wybrano portfeli + no_accounts: Brak portfeli do importu + coinbase_item: + provider_name: Coinbase + syncing: Synchronizacja... + reconnect: Dane uwierzytelniające wymagają aktualizacji + deletion_in_progress: Usuwanie... + sync_status: + no_accounts: Nie znaleziono kont + all_synced: + one: "%{count} konto zsynchronizowane" + few: "%{count} konta zsynchronizowane" + many: "%{count} kont zsynchronizowanych" + other: "%{count} kont zsynchronizowanych" + partial_sync: "%{linked_count} zsynchronizowanych, %{unlinked_count} wymaga konfiguracji" + status: Ostatnia synchronizacja %{timestamp} temu + status_with_summary: Ostatnia synchronizacja %{timestamp} temu - %{summary} + status_never: Nigdy nie synchronizowano + update_credentials: Zaktualizuj dane uwierzytelniające + delete: Usuń + no_accounts_title: Nie znaleziono kont + no_accounts_message: Twoje portfele Coinbase pojawią się tutaj po synchronizacji. + setup_needed: Portfele gotowe do importu + setup_description: Wybierz portfele Coinbase, które chcesz śledzić. + setup_action: Importuj portfele + import_wallets_menu: Importuj portfele + more_wallets_available: + one: "Jeszcze %{count} portfel do zaimportowania" + few: "Jeszcze %{count} portfele do zaimportowania" + many: "Jeszcze %{count} portfeli do zaimportowania" + other: "Jeszcze %{count} portfeli do zaimportowania" + select_existing_account: + title: Połącz konto Coinbase + no_accounts_found: Nie znaleziono kont Coinbase. + wait_for_sync: Poczekaj, aż Coinbase zakończy synchronizację + check_provider_health: Sprawdź, czy dane API Coinbase są prawidłowe + balance: Saldo + currently_linked_to: 'Aktualnie połączone z: %{account_name}' + link: Połącz + cancel: Anuluj + link_existing_account: + success: Pomyślnie połączono z kontem Coinbase + errors: + only_manual: Z Coinbase można łączyć tylko konta manualne + invalid_coinbase_account: Nieprawidłowe konto Coinbase + coinbase_item: + syncer: + checking_credentials: Sprawdzanie danych uwierzytelniających... + credentials_invalid: Nieprawidłowe dane API. Sprawdź klucz i sekret API. + importing_accounts: Importowanie kont z Coinbase... + checking_configuration: Sprawdzanie konfiguracji kont... + accounts_need_setup: + one: "%{count} konto wymaga konfiguracji" + few: "%{count} konta wymagają konfiguracji" + many: "%{count} kont wymaga konfiguracji" + other: "%{count} kont wymaga konfiguracji" + processing_accounts: Przetwarzanie danych kont... + calculating_balances: Obliczanie sald... diff --git a/config/locales/views/coinstats_items/de.yml b/config/locales/views/coinstats_items/de.yml new file mode 100644 index 000000000..56ce87bd8 --- /dev/null +++ b/config/locales/views/coinstats_items/de.yml @@ -0,0 +1,63 @@ +--- +de: + coinstats_items: + create: + success: CoinStats-Provider-Verbindung wurde erfolgreich eingerichtet. + default_name: CoinStats-Verbindung + errors: + validation_failed: "Validierung fehlgeschlagen: %{message}." + update: + success: CoinStats-Provider-Verbindung wurde erfolgreich aktualisiert. + errors: + validation_failed: "Validierung fehlgeschlagen: %{message}." + destroy: + success: CoinStats-Provider-Verbindung wurde zur Löschung vorgemerkt. + link_wallet: + success: "%{count} Krypto-Wallet(s) erfolgreich verknüpft." + missing_params: "Fehlende erforderliche Parameter: Adresse und Blockchain." + failed: Verknüpfung des Krypto-Wallets fehlgeschlagen. + error: "Krypto-Wallet-Verknüpfung fehlgeschlagen: %{message}." + new: + title: Krypto-Wallet mit CoinStats verknüpfen + blockchain_fetch_error: Blockchains konnten nicht geladen werden. Bitte später erneut versuchen. + address_label: Adresse + address_placeholder: Erforderlich + blockchain_label: Blockchain + blockchain_placeholder: Erforderlich + blockchain_select_blank: Blockchain auswählen + link: Krypto-Wallet verknüpfen + not_configured_title: CoinStats-Provider-Verbindung nicht konfiguriert + not_configured_message: Zum Verknüpfen eines Krypto-Wallets müssen Sie zuerst die CoinStats-Provider-Verbindung konfigurieren. + not_configured_step1_html: Gehen Sie zu Einstellungen → Provider + not_configured_step2_html: Suchen Sie den CoinStats-Provider + not_configured_step3_html: Folgen Sie den Einrichtungsanweisungen zur Konfiguration + go_to_settings: Zu den Provider-Einstellungen + setup_instructions: "Einrichtungsanleitung:" + step1_html: Besuchen Sie das CoinStats Public API Dashboard, um einen API-Key zu erhalten. + step2: Tragen Sie Ihren API-Key unten ein und klicken Sie auf Konfigurieren. + step3_html: Nach erfolgreicher Verbindung gehen Sie zum Konten-Tab, um Krypto-Wallets einzurichten. + api_key_label: API-Key + api_key_placeholder: Erforderlich + configure: Konfigurieren + update_configuration: Neu konfigurieren + default_name: CoinStats-Verbindung + status_configured_html: Bereit zur Nutzung + status_not_configured: Nicht konfiguriert + coinstats_item: + deletion_in_progress: Krypto-Wallet-Daten werden gelöscht… + provider_name: CoinStats + syncing: Synchronisiere… + sync_status: + no_accounts: Keine Krypto-Wallets gefunden + all_synced: + one: "%{count} Krypto-Wallet synchronisiert" + other: "%{count} Krypto-Wallets synchronisiert" + partial_sync: "%{linked_count} Krypto-Wallets synchronisiert, %{unlinked_count} müssen eingerichtet werden" + reconnect: Erneut verbinden + status: Zuletzt synchronisiert vor %{timestamp} + status_never: Noch nie synchronisiert + status_with_summary: "Zuletzt synchronisiert vor %{timestamp} • %{summary}" + update_api_key: API-Key aktualisieren + delete: Löschen + no_wallets_title: Keine Krypto-Wallets verbunden + no_wallets_message: Derzeit sind keine Krypto-Wallets mit CoinStats verbunden. diff --git a/config/locales/views/coinstats_items/en.yml b/config/locales/views/coinstats_items/en.yml index 00add222d..ecd41109b 100644 --- a/config/locales/views/coinstats_items/en.yml +++ b/config/locales/views/coinstats_items/en.yml @@ -17,17 +17,31 @@ en: missing_params: "Missing required parameters: address and blockchain." failed: Crypto wallet linking failed. error: "Crypto wallet linking failed: %{message}." + link_exchange: + success: "%{name} exchange linked." + missing_params: Exchange and credentials are required. + invalid_exchange: Selected exchange is no longer supported. + failed: Failed to link exchange. + error: "Failed to link exchange: %{message}." new: - title: Link a Crypto Wallet with CoinStats + title: Link Crypto with CoinStats blockchain_fetch_error: Failed load Blockchains. Please try again later. + link_wallet_title: Link Wallet Address + link_wallet_description: Track a self-custody wallet or a single on-chain address through CoinStats. address_label: Address address_placeholder: Required blockchain_label: Blockchain blockchain_placeholder: Required blockchain_select_blank: Select a Blockchain - link: Link Crypto Wallet + link_wallet_submit: Link Crypto Wallet + link_exchange_title: Link Exchange API + link_exchange_description: Use a read-only exchange API key so CoinStats can sync balances and transactions from Bitvavo, Binance, and other supported exchanges. + link_exchange_note: If your exchange requires API-key activation or email confirmation, complete that step before linking here. + exchange_select_blank: Select an exchange + exchange_label: Exchange + link_exchange_submit: Link Exchange not_configured_title: CoinStats provider connection not configured - not_configured_message: To link a crypto wallet, you must first configure the CoinStats provider connection. + not_configured_message: To link a crypto wallet or exchange, you must first configure the CoinStats provider connection. not_configured_step1_html: Go to Settings → Providers not_configured_step2_html: Locate the CoinStats provider not_configured_step3_html: Follow the provided setup Instructions to complete provider configuration @@ -35,7 +49,7 @@ en: setup_instructions: "Setup Instructions:" step1_html: Visit the CoinStats Public API Dashboard to obtain an API key. step2: Enter your API key below and click Configure. - step3_html: After a successful connection, visit the Accounts tab to set up crypto wallets. + step3_html: After a successful connection, visit the Accounts tab to set up your crypto accounts. api_key_label: API Key api_key_placeholder: Required configure: Configure diff --git a/config/locales/views/coinstats_items/es.yml b/config/locales/views/coinstats_items/es.yml new file mode 100644 index 000000000..36744d53e --- /dev/null +++ b/config/locales/views/coinstats_items/es.yml @@ -0,0 +1,63 @@ +--- +es: + coinstats_items: + create: + success: Conexión del proveedor CoinStats configurada correctamente. + default_name: Conexión de CoinStats + errors: + validation_failed: "Error de validación: %{message}." + update: + success: Conexión del proveedor CoinStats actualizada correctamente. + errors: + validation_failed: "Error de validación: %{message}." + destroy: + success: Conexión del proveedor CoinStats programada para su eliminación. + link_wallet: + success: "%{count} cartera(s) de criptomonedas vinculada(s) correctamente." + missing_params: "Faltan parámetros requeridos: dirección y blockchain." + failed: Error al vincular la cartera de criptomonedas. + error: "Error al vincular la cartera de criptomonedas: %{message}." + new: + title: Vincular una cartera de criptomonedas con CoinStats + blockchain_fetch_error: Error al cargar las Blockchains. Por favor, inténtalo de nuevo más tarde. + address_label: Dirección + address_placeholder: Obligatorio + blockchain_label: Blockchain + blockchain_placeholder: Obligatorio + blockchain_select_blank: Selecciona una Blockchain + link: Vincular cartera de criptomonedas + not_configured_title: Conexión del proveedor CoinStats no configurada + not_configured_message: Para vincular una cartera de criptomonedas, primero debes configurar la conexión del proveedor CoinStats. + not_configured_step1_html: Ve a Ajustes → Proveedores + not_configured_step2_html: Localiza el proveedor CoinStats + not_configured_step3_html: Sigue las instrucciones de configuración proporcionadas para completar la configuración del proveedor + go_to_settings: Ir a Ajustes de proveedores + setup_instructions: "Instrucciones de configuración:" + step1_html: Visita el Panel de la API pública de CoinStats para obtener una clave API. + step2: Introduce tu clave API a continuación y haz clic en Configurar. + step3_html: Tras una conexión exitosa, visita la pestaña de Cuentas para configurar tus carteras de criptomonedas. + api_key_label: Clave API + api_key_placeholder: Obligatorio + configure: Configurar + update_configuration: Reconfigurar + default_name: Conexión de CoinStats + status_configured_html: Listo para usar + status_not_configured: No configurado + coinstats_item: + deletion_in_progress: Los datos de la cartera de criptomonedas se están eliminando… + provider_name: CoinStats + syncing: Sincronizando… + sync_status: + no_accounts: No se han encontrado carteras de criptomonedas + all_synced: + one: "%{count} cartera de criptomonedas sincronizada" + other: "%{count} carteras de criptomonedas sincronizadas" + partial_sync: "%{linked_count} carteras de criptomonedas sincronizadas, %{unlinked_count} necesitan configuración" + reconnect: Reconectar + status: Sincronizado hace %{timestamp} + status_never: Nunca sincronizado + status_with_summary: "Sincronizado hace %{timestamp} • %{summary}" + update_api_key: Actualizar clave API + delete: Eliminar + no_wallets_title: No hay carteras de criptomonedas conectadas + no_wallets_message: Actualmente no hay carteras de criptomonedas conectadas a CoinStats. \ No newline at end of file diff --git a/config/locales/views/coinstats_items/pl.yml b/config/locales/views/coinstats_items/pl.yml new file mode 100644 index 000000000..968c9aa0b --- /dev/null +++ b/config/locales/views/coinstats_items/pl.yml @@ -0,0 +1,69 @@ +--- +pl: + coinstats_items: + create: + success: Połączenie z dostawcą CoinStats zostało pomyślnie skonfigurowane. + default_name: Połączenie CoinStats + errors: + validation_failed: 'Walidacja nie powiodła się: %{message}.' + update: + success: Połączenie z dostawcą CoinStats zostało pomyślnie zaktualizowane. + errors: + validation_failed: 'Walidacja nie powiodła się: %{message}.' + destroy: + success: Połączenie z dostawcą CoinStats zostało zaplanowane do usunięcia. + link_wallet: + success: + one: "Pomyślnie połączono %{count} portfel kryptowalutowy." + few: "Pomyślnie połączono %{count} portfele kryptowalutowe." + many: "Pomyślnie połączono %{count} portfeli kryptowalutowych." + other: "Pomyślnie połączono %{count} portfela kryptowalutowego." + missing_params: 'Brak wymaganych parametrów: address i blockchain.' + failed: Łączenie portfela kryptowalutowego nie powiodło się. + error: 'Łączenie portfela kryptowalutowego nie powiodło się: %{message}.' + new: + title: Połącz portfel kryptowalutowy z CoinStats + blockchain_fetch_error: Nie udało się wczytać blockchainów. Spróbuj ponownie później. + address_label: Adres + address_placeholder: Wymagane + blockchain_label: Blockchain + blockchain_placeholder: Wymagane + blockchain_select_blank: Wybierz blockchain + link: Połącz portfel kryptowalutowy + not_configured_title: Połączenie z dostawcą CoinStats nie jest skonfigurowane + not_configured_message: Aby połączyć portfel kryptowalutowy, najpierw skonfiguruj połączenie z dostawcą CoinStats. + not_configured_step1_html: Przejdź do Ustawienia → Dostawcy + not_configured_step2_html: Znajdź dostawcę CoinStats + not_configured_step3_html: Postępuj zgodnie z podanymi instrukcjami konfiguracji, aby ukończyć konfigurację dostawcy + go_to_settings: Przejdź do ustawień dostawców + setup_instructions: 'Instrukcje konfiguracji:' + step1_html: Odwiedź panel publicznego API CoinStats, aby uzyskać klucz API. + step2: Wprowadź poniżej swój klucz API i kliknij Skonfiguruj. + step3_html: Po udanym połączeniu przejdź do zakładki Konta, aby skonfigurować portfele kryptowalutowe. + api_key_label: Klucz API + api_key_placeholder: Wymagane + configure: Skonfiguruj + update_configuration: Skonfiguruj ponownie + default_name: Połączenie CoinStats + status_configured_html: Gotowe do użycia + status_not_configured: Nieskonfigurowane + coinstats_item: + deletion_in_progress: Trwa usuwanie danych portfela kryptowalutowego… + provider_name: CoinStats + syncing: Synchronizacja… + sync_status: + no_accounts: Nie znaleziono portfeli kryptowalutowych + all_synced: + one: "Zsynchronizowano %{count} portfel kryptowalutowy" + few: "Zsynchronizowano %{count} portfele kryptowalutowe" + many: "Zsynchronizowano %{count} portfeli kryptowalutowych" + other: "Zsynchronizowano %{count} portfeli kryptowalutowych" + partial_sync: "Zsynchronizowano %{linked_count} portfeli kryptowalutowych, %{unlinked_count} wymaga konfiguracji" + reconnect: Połącz ponownie + status: Ostatnia synchronizacja %{timestamp} temu + status_never: Nigdy nie synchronizowano + status_with_summary: Ostatnia synchronizacja %{timestamp} temu • %{summary} + update_api_key: Zaktualizuj klucz API + delete: Usuń + no_wallets_title: Brak połączonych portfeli kryptowalutowych + no_wallets_message: Obecnie żadne portfele kryptowalutowe nie są połączone z CoinStats. diff --git a/config/locales/views/components/de.yml b/config/locales/views/components/de.yml new file mode 100644 index 000000000..ec3e70fed --- /dev/null +++ b/config/locales/views/components/de.yml @@ -0,0 +1,67 @@ +--- +de: + provider_sync_summary: + title: Sync-Zusammenfassung + last_sync: "Letzter Sync: vor %{time_ago}" + accounts: + title: Konten + total: "Gesamt: %{count}" + linked: "Verknüpft: %{count}" + unlinked: "Nicht verknüpft: %{count}" + institutions: "Institute: %{count}" + transactions: + title: Buchungen + seen: "Erfasst: %{count}" + imported: "Importiert: %{count}" + updated: "Aktualisiert: %{count}" + skipped: "Übersprungen: %{count}" + fetching: "Wird vom Broker abgerufen..." + protected: + one: "%{count} Buchung geschützt (nicht überschrieben)" + other: "%{count} Buchungen geschützt (nicht überschrieben)" + view_protected: Geschützte Buchungen anzeigen + skip_reasons: + excluded: Ausgeschlossen + user_modified: Vom Benutzer geändert + import_locked: CSV-Import + protected: Geschützt + holdings: + title: Bestände + found: "Gefunden: %{count}" + processed: "Verarbeitet: %{count}" + trades: + title: Trades + imported: "Importiert: %{count}" + skipped: "Übersprungen: %{count}" + fetching: "Aktivitäten werden vom Broker abgerufen..." + health: + title: Status + view_error_details: Fehlerdetails anzeigen + rate_limited: "Rate-Limit vor %{time_ago}" + recently: kürzlich + errors: "Fehler: %{count}" + pending_reconciled: + one: "%{count} ausstehende Doppelbuchung abgeglichen" + other: "%{count} ausstehende Doppelbuchungen abgeglichen" + view_reconciled: Abgeglichene Buchungen anzeigen + duplicate_suggestions: + one: "%{count} mögliche Doppelbuchung zur Prüfung" + other: "%{count} mögliche Doppelbuchungen zur Prüfung" + view_duplicate_suggestions: Vorgeschlagene Duplikate anzeigen + stale_pending: + one: "%{count} veraltete ausstehende Buchung (von Budgets ausgeschlossen)" + other: "%{count} veraltete ausstehende Buchungen (von Budgets ausgeschlossen)" + view_stale_pending: Betroffene Konten anzeigen + stale_pending_count: + one: "%{count} Buchung" + other: "%{count} Buchungen" + stale_unmatched: + one: "%{count} ausstehende Buchung benötigt manuelle Prüfung" + other: "%{count} ausstehende Buchungen benötigen manuelle Prüfung" + view_stale_unmatched: Zu prüfende Buchungen anzeigen + stale_unmatched_count: + one: "%{count} Buchung" + other: "%{count} Buchungen" + data_warnings: "Datenwarnungen: %{count}" + notices: "Hinweise: %{count}" + view_data_quality: Datenqualitätsdetails anzeigen diff --git a/config/locales/views/components/es.yml b/config/locales/views/components/es.yml new file mode 100644 index 000000000..4587a0088 --- /dev/null +++ b/config/locales/views/components/es.yml @@ -0,0 +1,66 @@ +es: + provider_sync_summary: + title: Resumen de sincronización + last_sync: "Última sincronización: hace %{time_ago}" + accounts: + title: Cuentas + total: "Total: %{count}" + linked: "Vinculadas: %{count}" + unlinked: "Desvinculadas: %{count}" + institutions: "Instituciones: %{count}" + transactions: + title: Transacciones + seen: "Detectadas: %{count}" + imported: "Importadas: %{count}" + updated: "Actualizadas: %{count}" + skipped: "Omitidas: %{count}" + fetching: "Obteniendo del bróker..." + protected: + one: "%{count} entrada protegida (no sobrescrita)" + other: "%{count} entradas protegidas (no sobrescritas)" + view_protected: Ver entradas protegidas + skip_reasons: + excluded: Excluida + user_modified: Modificada por el usuario + import_locked: Importación CSV + protected: Protegida + holdings: + title: Posiciones + found: "Encontradas: %{count}" + processed: "Procesadas: %{count}" + trades: + title: Operaciones + imported: "Importadas: %{count}" + skipped: "Omitidas: %{count}" + fetching: "Obteniendo actividades del bróker..." + health: + title: Estado de salud + view_error_details: Ver detalles del error + rate_limited: "Límite de frecuencia alcanzado hace %{time_ago}" + recently: recientemente + errors: "Errores: %{count}" + pending_reconciled: + one: "%{count} transacción pendiente duplicada conciliada" + other: "%{count} transacciones pendientes duplicadas conciliadas" + view_reconciled: Ver transacciones conciliadas + duplicate_suggestions: + one: "%{count} posible duplicado necesita revisión" + other: "%{count} posibles duplicados necesitan revisión" + view_duplicate_suggestions: Ver duplicados sugeridos + stale_pending: + one: "%{count} transacción pendiente antigua (excluida de presupuestos)" + other: "%{count} transacciones pendientes antiguas (excluidas de presupuestos)" + view_stale_pending: Ver cuentas afectadas + stale_pending_count: + one: "%{count} transacción" + other: "%{count} transacciones" + stale_unmatched: + one: "%{count} transacción pendiente necesita revisión manual" + other: "%{count} transacciones pendientes necesitan revisión manual" + view_stale_unmatched: Ver transacciones que necesitan revisión + stale_unmatched_count: + one: "%{count} transacción" + other: "%{count} transacciones" + data_warnings: "Avisos de datos: %{count}" + notices: "Avisos: %{count}" + view_data_quality: Ver detalles de calidad de datos \ No newline at end of file diff --git a/config/locales/views/components/pl.yml b/config/locales/views/components/pl.yml new file mode 100644 index 000000000..3c55c50f6 --- /dev/null +++ b/config/locales/views/components/pl.yml @@ -0,0 +1,81 @@ +--- +pl: + provider_sync_summary: + title: Podsumowanie synchronizacji + last_sync: 'Ostatnia synchronizacja: %{time_ago} temu' + accounts: + title: Konta + total: 'Łącznie: %{count}' + linked: 'Połączone: %{count}' + unlinked: 'Niepołączone: %{count}' + institutions: 'Instytucje: %{count}' + transactions: + title: Transakcje + seen: 'Wykryte: %{count}' + imported: 'Zaimportowane: %{count}' + updated: 'Zaktualizowane: %{count}' + skipped: 'Pominięte: %{count}' + fetching: Pobieranie z biura maklerskiego... + protected: + one: "%{count} wpis chroniony (nie nadpisano)" + few: "%{count} wpisy chronione (nie nadpisano)" + many: "%{count} wpisów chronionych (nie nadpisano)" + other: "%{count} wpisów chronionych (nie nadpisano)" + view_protected: Zobacz chronione wpisy + skip_reasons: + excluded: Wykluczone + user_modified: Zmodyfikowane przez użytkownika + import_locked: Import z CSV + protected: Chronione + holdings: + title: Pozycje + found: 'Znalezione: %{count}' + processed: 'Przetworzone: %{count}' + trades: + title: Transakcje giełdowe + imported: 'Zaimportowane: %{count}' + skipped: 'Pominięte: %{count}' + fetching: Pobieranie aktywności z biura maklerskiego... + health: + title: Kondycja + view_error_details: Zobacz szczegóły błędów + rate_limited: Ograniczenie limitu %{time_ago} + recently: niedawno + errors: 'Błędy: %{count}' + pending_reconciled: + one: "Uzgodniono %{count} zduplikowaną transakcję oczekującą" + few: "Uzgodniono %{count} zduplikowane transakcje oczekujące" + many: "Uzgodniono %{count} zduplikowanych transakcji oczekujących" + other: "Uzgodniono %{count} zduplikowanych transakcji oczekujących" + view_reconciled: Zobacz uzgodnione transakcje + duplicate_suggestions: + one: "%{count} możliwy duplikat wymaga przeglądu" + few: "%{count} możliwe duplikaty wymagają przeglądu" + many: "%{count} możliwych duplikatów wymaga przeglądu" + other: "%{count} możliwe duplikaty wymagają przeglądu" + view_duplicate_suggestions: Zobacz sugerowane duplikaty + stale_pending: + one: "%{count} przestarzała transakcja oczekująca (wykluczona z budżetów)" + few: "%{count} przestarzałe transakcje oczekujące (wykluczone z budżetów)" + many: "%{count} przestarzałych transakcji oczekujących (wykluczonych z budżetów)" + other: "%{count} przestarzałych transakcji oczekujących (wykluczonych z budżetów)" + view_stale_pending: Zobacz dotknięte konta + stale_pending_count: + one: "%{count} transakcja" + few: "%{count} transakcje" + many: "%{count} transakcji" + other: "%{count} transakcji" + stale_unmatched: + one: "%{count} transakcja oczekująca wymaga ręcznego przeglądu" + few: "%{count} transakcje oczekujące wymagają ręcznego przeglądu" + many: "%{count} transakcji oczekujących wymaga ręcznego przeglądu" + other: "%{count} transakcji oczekujących wymaga ręcznego przeglądu" + view_stale_unmatched: Zobacz transakcje wymagające przeglądu + stale_unmatched_count: + one: "%{count} transakcja" + few: "%{count} transakcje" + many: "%{count} transakcji" + other: "%{count} transakcji" + data_warnings: 'Ostrzeżenia danych: %{count}' + notices: 'Powiadomienia: %{count}' + view_data_quality: Zobacz szczegóły jakości danych diff --git a/config/locales/views/components/pt-BR.yml b/config/locales/views/components/pt-BR.yml new file mode 100644 index 000000000..c496a0ac9 --- /dev/null +++ b/config/locales/views/components/pt-BR.yml @@ -0,0 +1,67 @@ +--- +pt-BR: + provider_sync_summary: + title: Resumo da sincronização + last_sync: "Última sincronização: há %{time_ago}" + accounts: + title: Contas + total: "Total: %{count}" + linked: "Vinculado: %{count}" + unlinked: "Não vinculado: %{count}" + institutions: "Instituições: %{count}" + transactions: + title: Transações + seen: "Vistos: %{count}" + imported: "Importado: %{count}" + updated: "Atualizado: %{count}" + skipped: "Ignorada: %{count}" + fetching: "Buscando na corretora..." + protected: + one: "%{count} entrada protegida (não sobrescrita)" + other: "%{count} entradas protegidas (não sobrescritas)" + view_protected: Ver entradas protegidas + skip_reasons: + excluded: Excluída + user_modified: Usuário modificou + import_locked: Importação de CSV + protected: Protegida + holdings: + title: Holdings + found: "Encontrado: %{count}" + processed: "Processados: %{count}" + trades: + title: Trocas + imported: "Importado: %{count}" + skipped: "Ignorado: %{count}" + fetching: "Buscar atividades da corretora..." + health: + title: Saúde + view_error_details: Ver detalhes do erro + rate_limited: "Taxa limitada %{time_ago}" + recently: recentemente + errors: "Erros: %{count}" + pending_reconciled: + one: "%{count} transações pendentes duplicadas reconciliadas" + other: "%{count} transações pendentes duplicadas reconciliadas" + view_reconciled: Visualizar transações conciliadas + duplicate_suggestions: + one: "%{count} possível duplicado precisa de revisão" + other: "%{count} possíveis duplicados precisam ser revisados" + view_duplicate_suggestions: Ver duplicados sugeridos + stale_pending: + one: "%{count} transações pendentes obsoletas (excluídas dos orçamentos)" + other: "%{count} transações pendentes obsoletas (excluídas dos orçamentos)" + view_stale_pending: Veja as contas afetadas + stale_pending_count: + one: "%{count} transação" + other: "%{count} transações" + stale_unmatched: + one: "%{count} transação pendente precisa de revisão manual" + other: "%{count} transações pendentes precisam de revisão manual" + view_stale_unmatched: Veja as transações que precisam de revisão. + stale_unmatched_count: + one: "%{count} transação" + other: "%{count} transações" + data_warnings: "Avisos de dados: %{count}" + notices: "Avisos: %{count}" + view_data_quality: Veja os detalhes da qualidade dos dados. diff --git a/config/locales/views/credit_cards/pl.yml b/config/locales/views/credit_cards/pl.yml new file mode 100644 index 000000000..2b5a0fa81 --- /dev/null +++ b/config/locales/views/credit_cards/pl.yml @@ -0,0 +1,25 @@ +--- +pl: + credit_cards: + edit: + edit: Edytuj %{account} + form: + annual_fee: Opłata roczna + annual_fee_placeholder: '99' + apr: RRSO + apr_placeholder: '15.99' + available_credit: Dostępny limit kredytowy + available_credit_placeholder: '10000' + expiration_date: Data ważności + minimum_payment: Minimalna spłata + minimum_payment_placeholder: '100' + new: + title: Wprowadź dane karty kredytowej + overview: + amount_owed: Kwota zadłużenia + annual_fee: Opłata roczna + apr: RRSO + available_credit: Dostępny limit kredytowy + expiration_date: Data ważności + minimum_payment: Minimalna spłata + unknown: Nieznane diff --git a/config/locales/views/cryptos/de.yml b/config/locales/views/cryptos/de.yml index 06039fa62..250562848 100644 --- a/config/locales/views/cryptos/de.yml +++ b/config/locales/views/cryptos/de.yml @@ -3,5 +3,18 @@ de: cryptos: edit: edit: "%{account} bearbeiten" + form: + subtype_label: Kontotyp + subtype_prompt: Typ auswählen... + subtype_none: Nicht angegeben + tax_treatment_label: Steuerbehandlung + tax_treatment_hint: Die meisten Kryptowährungen werden in versteuerten Konten gehalten. Wählen Sie eine andere Option bei steuerbegünstigten Konten (z. B. selbst verwaltetes IRA). new: title: Kontostand eingeben + subtypes: + wallet: + short: Wallet + long: Krypto-Wallet + exchange: + short: Börse + long: Krypto-Börse diff --git a/config/locales/views/cryptos/en.yml b/config/locales/views/cryptos/en.yml index 26649e9ad..d2a4e3ddd 100644 --- a/config/locales/views/cryptos/en.yml +++ b/config/locales/views/cryptos/en.yml @@ -5,10 +5,10 @@ en: edit: Edit %{account} form: subtype_label: Account type - subtype_prompt: Select type... - subtype_none: Not specified + subtype_prompt: Select account type + subtype_none: None tax_treatment_label: Tax Treatment - tax_treatment_hint: Most cryptocurrency is held in taxable accounts. Select a different option if held in a tax-advantaged account like a self-directed IRA. + tax_treatment_hint: Most cryptocurrency is held in taxable accounts. Select a different option if held in a tax-advantaged account. new: title: Enter account balance subtypes: diff --git a/config/locales/views/cryptos/es.yml b/config/locales/views/cryptos/es.yml index 15a8125f5..14ea42ded 100644 --- a/config/locales/views/cryptos/es.yml +++ b/config/locales/views/cryptos/es.yml @@ -3,5 +3,18 @@ es: cryptos: edit: edit: Editar %{account} + form: + subtype_label: Tipo de cuenta + subtype_prompt: Seleccionar tipo... + subtype_none: No especificado + tax_treatment_label: Tratamiento fiscal + tax_treatment_hint: La mayoría de las criptomonedas se mantienen en cuentas sujetas a impuestos. Selecciona una opción diferente si se mantienen en una cuenta con ventajas fiscales. new: title: Introducir saldo de la cuenta + subtypes: + wallet: + short: Cartera + long: Cartera de criptomonedas + exchange: + short: Exchange + long: Exchange de criptomonedas \ No newline at end of file diff --git a/config/locales/views/cryptos/pl.yml b/config/locales/views/cryptos/pl.yml new file mode 100644 index 000000000..6dc0cfe87 --- /dev/null +++ b/config/locales/views/cryptos/pl.yml @@ -0,0 +1,20 @@ +--- +pl: + cryptos: + edit: + edit: Edytuj %{account} + form: + subtype_label: Typ konta + subtype_prompt: Wybierz typ... + subtype_none: Nie określono + tax_treatment_label: Sposób opodatkowania + tax_treatment_hint: Większość kryptowalut jest utrzymywana na kontach opodatkowanych. Wybierz inną opcję, jeśli środki są trzymane na koncie uprzywilejowanym podatkowo, np. samodzielnie prowadzonym IRA. + new: + title: Wprowadź saldo konta + subtypes: + wallet: + short: Portfel + long: Portfel kryptowalutowy + exchange: + short: Giełda + long: Giełda kryptowalut diff --git a/config/locales/views/depositories/pl.yml b/config/locales/views/depositories/pl.yml new file mode 100644 index 000000000..5f526fb2b --- /dev/null +++ b/config/locales/views/depositories/pl.yml @@ -0,0 +1,10 @@ +--- +pl: + depositories: + edit: + edit: Edytuj %{account} + form: + none: Brak + subtype_prompt: Wybierz typ konta + new: + title: Wprowadź saldo konta diff --git a/config/locales/views/email_confirmation_mailer/pl.yml b/config/locales/views/email_confirmation_mailer/pl.yml new file mode 100644 index 000000000..6f086bd37 --- /dev/null +++ b/config/locales/views/email_confirmation_mailer/pl.yml @@ -0,0 +1,9 @@ +--- +pl: + email_confirmation_mailer: + confirmation_email: + body: Niedawno poproszono o zmianę Twojego adresu e-mail. Kliknij przycisk poniżej, aby potwierdzić tę zmianę. + cta: Potwierdź zmianę e-maila + expiry_notice: Ten link wygaśnie za %{hours} godzin. + greeting: Cześć! + subject: "%{product_name}: potwierdź zmianę adresu e-mail" diff --git a/config/locales/views/enable_banking_items/de.yml b/config/locales/views/enable_banking_items/de.yml new file mode 100644 index 000000000..c7ead9255 --- /dev/null +++ b/config/locales/views/enable_banking_items/de.yml @@ -0,0 +1,49 @@ +--- +de: + enable_banking_items: + authorize: + authorization_failed: Autorisierung konnte nicht gestartet werden + bank_required: Bitte wählen Sie eine Bank. + invalid_redirect: Die erhaltene Autorisierungs-URL ist ungültig. Bitte versuchen Sie es erneut. + redirect_uri_not_allowed: Weiterleitung nicht erlaubt. Konfigurieren Sie %{callback_url} in den Enable-Banking-App-Einstellungen. + unexpected_error: Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut. + callback: + authorization_error: Autorisierung fehlgeschlagen + invalid_callback: Ungültige Callback-Parameter. + item_not_found: Verbindung nicht gefunden. + session_failed: Autorisierung konnte nicht abgeschlossen werden + success: Erfolgreich mit Ihrer Bank verbunden. Ihre Konten werden synchronisiert. + unexpected_error: Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut. + complete_account_setup: + all_skipped: Alle Konten wurden übersprungen. Sie können sie später auf der Konten-Seite einrichten. + no_accounts: Keine Konten zum Einrichten verfügbar. + success: "%{count} Konten wurden erfolgreich angelegt!" + create: + success: Enable-Banking-Konfiguration erfolgreich. + destroy: + success: Die Enable-Banking-Verbindung wurde zur Löschung vorgemerkt. + link_accounts: + already_linked: Die ausgewählten Konten sind bereits verknüpft. + link_failed: Konten konnten nicht verknüpft werden + no_accounts_selected: Keine Konten ausgewählt. + no_session: Keine aktive Enable-Banking-Verbindung. Bitte verbinden Sie zuerst eine Bank. + success: "%{count} Konten wurden erfolgreich verknüpft." + link_existing_account: + success: Konto erfolgreich mit Enable-Banking verknüpft + errors: + only_manual: Nur manuelle Konten können verknüpft werden + invalid_enable_banking_account: Ungültiges Enable-Banking-Konto ausgewählt + new: + link_enable_banking_title: Enable-Banking verknüpfen + reauthorize: + invalid_redirect: Die erhaltene Autorisierungs-URL ist ungültig. Bitte versuchen Sie es erneut. + reauthorization_failed: Erneute Autorisierung fehlgeschlagen + select_bank: + cancel: Abbrechen + check_country: Bitte prüfen Sie Ihre Ländereinstellungen. + credentials_required: Bitte konfigurieren Sie zuerst Ihre Enable-Banking-Zugangsdaten. + description: Wählen Sie die Bank, die Sie mit Ihren Konten verbinden möchten. + no_banks: Für diese Region/Land sind keine Banken verfügbar. + title: Wählen Sie Ihre Bank + update: + success: Enable-Banking-Konfiguration aktualisiert. diff --git a/config/locales/views/enable_banking_items/es.yml b/config/locales/views/enable_banking_items/es.yml new file mode 100644 index 000000000..62d890f0e --- /dev/null +++ b/config/locales/views/enable_banking_items/es.yml @@ -0,0 +1,49 @@ +--- +es: + enable_banking_items: + authorize: + authorization_failed: Error al iniciar la autorización + bank_required: Por favor, selecciona un banco. + invalid_redirect: La URL de autorización recibida no es válida. Por favor, inténtalo de nuevo. + redirect_uri_not_allowed: Redirección no permitida. Por favor, configura `%{callback_url}` en los ajustes de tu aplicación de Enable Banking. + unexpected_error: Ha ocurrido un error inesperado. Por favor, inténtalo de nuevo. + callback: + authorization_error: Error de autorización + invalid_callback: Parámetros de retorno (callback) no válidos. + item_not_found: Conexión no encontrada. + session_failed: No se ha podido completar la autorización + success: Conectado correctamente con tu banco. Tus cuentas se están sincronizando. + unexpected_error: Ha ocurrido un error inesperado. Por favor, inténtalo de nuevo. + complete_account_setup: + all_skipped: Se han omitido todas las cuentas. Puedes configurarlas más tarde en la página de cuentas. + no_accounts: No hay cuentas disponibles para configurar. + success: ¡Se han creado %{count} cuentas correctamente! + create: + success: Configuración de Enable Banking realizada con éxito. + destroy: + success: La conexión de Enable Banking se ha puesto en cola para su eliminación. + link_accounts: + already_linked: Las cuentas seleccionadas ya están vinculadas. + link_failed: Error al vincular las cuentas + no_accounts_selected: No se ha seleccionado ninguna cuenta. + no_session: No hay ninguna conexión activa de Enable Banking. Por favor, conéctate primero a un banco. + success: Se han vinculado %{count} cuentas correctamente. + link_existing_account: + success: Cuenta vinculada correctamente a Enable Banking + errors: + only_manual: Solo se pueden vincular cuentas manuales + invalid_enable_banking_account: Se ha seleccionado una cuenta de Enable Banking no válida + new: + link_enable_banking_title: Vincular Enable Banking + reauthorize: + invalid_redirect: La URL de autorización recibida no es válida. Por favor, inténtalo de nuevo. + reauthorization_failed: Error en la reautorización + select_bank: + cancel: Cancelar + check_country: Por favor, comprueba los ajustes de tu código de país. + credentials_required: Por favor, configura primero tus credenciales de Enable Banking. + description: Selecciona el banco que quieres conectar a tus cuentas. + no_banks: No hay bancos disponibles para este país/región. + title: Selecciona tu banco + update: + success: Configuración de Enable Banking actualizada. \ No newline at end of file diff --git a/config/locales/views/enable_banking_items/pl.yml b/config/locales/views/enable_banking_items/pl.yml new file mode 100644 index 000000000..596febecb --- /dev/null +++ b/config/locales/views/enable_banking_items/pl.yml @@ -0,0 +1,57 @@ +--- +pl: + enable_banking_items: + authorize: + authorization_failed: Nie udało się rozpocząć autoryzacji + bank_required: Wybierz bank. + invalid_redirect: Otrzymany adres URL autoryzacji jest nieprawidłowy. Spróbuj ponownie. + redirect_uri_not_allowed: Przekierowanie niedozwolone. Skonfiguruj `%{callback_url}` w ustawieniach aplikacji Enable Banking. + unexpected_error: Wystąpił nieoczekiwany błąd. Spróbuj ponownie. + callback: + authorization_error: Autoryzacja nie powiodła się + invalid_callback: Nieprawidłowe parametry callback. + item_not_found: Nie znaleziono połączenia. + session_failed: Nie udało się zakończyć autoryzacji + success: Pomyślnie połączono z bankiem. Twoje konta są synchronizowane. + unexpected_error: Wystąpił nieoczekiwany błąd. Spróbuj ponownie. + complete_account_setup: + all_skipped: Wszystkie konta zostały pominięte. Możesz skonfigurować je później na stronie kont. + no_accounts: Brak kont dostępnych do konfiguracji. + success: + one: Pomyślnie utworzono %{count} konto! + few: Pomyślnie utworzono %{count} konta! + many: Pomyślnie utworzono %{count} kont! + other: Pomyślnie utworzono %{count} konta! + create: + success: Konfiguracja Enable Banking zakończona pomyślnie. + destroy: + success: Połączenie Enable Banking zostało dodane do kolejki usuwania. + link_accounts: + already_linked: Wybrane konta są już połączone. + link_failed: Nie udało się połączyć kont + no_accounts_selected: Nie wybrano żadnych kont. + no_session: Brak aktywnego połączenia Enable Banking. Najpierw połącz się z bankiem. + success: + one: Pomyślnie połączono %{count} konto. + few: Pomyślnie połączono %{count} konta. + many: Pomyślnie połączono %{count} kont. + other: Pomyślnie połączono %{count} konta. + link_existing_account: + success: Konto zostało pomyślnie połączone z Enable Banking + errors: + only_manual: Można połączyć tylko konta manualne + invalid_enable_banking_account: Wybrano nieprawidłowe konto Enable Banking + new: + link_enable_banking_title: Połącz Enable Banking + reauthorize: + invalid_redirect: Otrzymany adres URL autoryzacji jest nieprawidłowy. Spróbuj ponownie. + reauthorization_failed: Ponowna autoryzacja nie powiodła się + select_bank: + cancel: Anuluj + check_country: Sprawdź ustawienia kodu kraju. + credentials_required: Najpierw skonfiguruj dane uwierzytelniające Enable Banking. + description: Wybierz bank, który chcesz połączyć ze swoimi kontami. + no_banks: Brak dostępnych banków dla tego kraju/regionu. + title: Wybierz swój bank + update: + success: Konfiguracja Enable Banking została zaktualizowana. diff --git a/config/locales/views/entries/de.yml b/config/locales/views/entries/de.yml index 4b08c0138..ca6893024 100644 --- a/config/locales/views/entries/de.yml +++ b/config/locales/views/entries/de.yml @@ -12,3 +12,12 @@ de: loading: Buchungen werden geladen... update: success: Buchung aktualisiert + unlock: + success: Buchung freigegeben. Sie kann beim nächsten Sync aktualisiert werden. + protection: + tooltip: Vor Sync geschützt + title: Vor Sync geschützt + description: Ihre Änderungen an dieser Buchung werden nicht durch den Provider-Sync überschrieben. + locked_fields_label: "Gesperrte Felder:" + unlock_button: Sync-Updates zulassen + unlock_confirm: Soll der Sync diese Buchung aktualisieren dürfen? Ihre Änderungen können beim nächsten Sync überschrieben werden. diff --git a/config/locales/views/entries/es.yml b/config/locales/views/entries/es.yml index 54b20e21d..e8dee8098 100644 --- a/config/locales/views/entries/es.yml +++ b/config/locales/views/entries/es.yml @@ -12,3 +12,12 @@ es: loading: Cargando entradas... update: success: Entrada actualizada + unlock: + success: Entrada desbloqueada. Podría actualizarse en la próxima sincronización. + protection: + tooltip: Protegido contra sincronización + title: Protegido contra sincronización + description: Los cambios que realices en esta entrada no serán sobrescritos por la sincronización del proveedor. + locked_fields_label: "Campos bloqueados:" + unlock_button: Permitir que la sincronización actualice + unlock_confirm: ¿Permitir que la sincronización actualice esta entrada? Tus cambios podrían sobrescribirse en la próxima sincronización. diff --git a/config/locales/views/entries/pl.yml b/config/locales/views/entries/pl.yml new file mode 100644 index 000000000..fc2f0a03b --- /dev/null +++ b/config/locales/views/entries/pl.yml @@ -0,0 +1,23 @@ +--- +pl: + entries: + create: + success: Wpis został utworzony + destroy: + success: Wpis został usunięty + empty: + description: Spróbuj dodać wpis, zmienić filtry lub doprecyzować wyszukiwanie + title: Nie znaleziono wpisów + loading: + loading: Ładowanie wpisów... + update: + success: Wpis został zaktualizowany + unlock: + success: Wpis został odblokowany. Może zostać zaktualizowany podczas następnej synchronizacji. + protection: + tooltip: Chronione przed synchronizacją + title: Chronione przed synchronizacją + description: Twoje zmiany w tym wpisie nie zostaną nadpisane przez synchronizację z dostawcą. + locked_fields_label: "Zablokowane pola:" + unlock_button: Zezwól synchronizacji na aktualizację + unlock_confirm: Zezwolić synchronizacji na aktualizację tego wpisu? Twoje zmiany mogą zostać nadpisane przy następnej synchronizacji. diff --git a/config/locales/views/entries/pt-BR.yml b/config/locales/views/entries/pt-BR.yml index c753c1f47..a0884417f 100644 --- a/config/locales/views/entries/pt-BR.yml +++ b/config/locales/views/entries/pt-BR.yml @@ -12,3 +12,12 @@ pt-BR: loading: Carregando entradas... update: success: Entrada atualizada + unlock: + success: Entrada desbloqueada. Ela poderá ser atualizada na próxima sincronização. + protection: + tooltip: Protegido contra sincronização + title: Protegido contra sincronização + description: As suas edições nesta entrada não serão sobrescritas pela sincronização do provedor. + locked_fields_label: "Campos bloqueados:" + unlock_button: Permitir sincronização para atualizar + unlock_confirm: Permitir que a sincronização atualize esta entrada? Suas alterações poderão ser sobrescritas na próxima sincronização. diff --git a/config/locales/views/family_exports/pl.yml b/config/locales/views/family_exports/pl.yml new file mode 100644 index 000000000..cdde24b00 --- /dev/null +++ b/config/locales/views/family_exports/pl.yml @@ -0,0 +1,31 @@ +--- +pl: + family_exports: + access_denied: Brak dostępu + create: + success: Eksport został rozpoczęty. Wkrótce będzie można go pobrać. + delete_confirmation: Czy na pewno chcesz usunąć ten eksport? Tej akcji nie można cofnąć. + delete_failed_confirmation: Czy na pewno chcesz usunąć ten nieudany eksport? + destroy: + success: Eksport został pomyślnie usunięty + export_not_ready: Eksport nie jest jeszcze gotowy do pobrania + exporting: Trwa eksportowanie... + index: + title: Eksporty + new: Nowy eksport + table: + title: Eksporty + header: + date: Data + filename: Nazwa pliku + status: Status + actions: Akcje + row: + status: + in_progress: W trakcie + complete: Zakończony + failed: Nieudany + actions: + delete: Usuń + download: Pobierz + empty: Brak eksportów. diff --git a/config/locales/views/holdings/de.yml b/config/locales/views/holdings/de.yml index 0d5bb4219..3954a23ad 100644 --- a/config/locales/views/holdings/de.yml +++ b/config/locales/views/holdings/de.yml @@ -5,9 +5,37 @@ de: brokerage_cash: Depotguthaben destroy: success: Position gelöscht + update: + success: Einstandspreis gespeichert. + error: Ungültiger Einstandspreis. + unlock_cost_basis: + success: Einstandspreis freigegeben. Er kann beim nächsten Sync aktualisiert werden. + remap_security: + success: Wertpapier erfolgreich aktualisiert. + security_not_found: Das ausgewählte Wertpapier konnte nicht gefunden werden. + reset_security: + success: Wertpapier auf Provider-Wert zurückgesetzt. + errors: + security_collision: "Umbildung nicht möglich: Sie haben bereits eine Position für %{ticker} am %{date}." + cost_basis_sources: + manual: Vom Benutzer gesetzt + calculated: Aus Trades + provider: Vom Provider + cost_basis_cell: + unknown: "--" + set_cost_basis_header: "Einstandspreis für %{ticker} setzen (%{qty} Anteile)" + total_cost_basis_label: Gesamter Einstandspreis + or_per_share_label: "Oder pro Anteil eingeben:" + per_share: pro Anteil + cancel: Abbrechen + save: Speichern + overwrite_confirm_title: Einstandspreis überschreiben? + overwrite_confirm_body: "Dies ersetzt den aktuellen Einstandspreis von %{current}." holding: per_share: pro Anteil shares: "%{qty} Anteile" + unknown: "--" + no_cost_basis: Kein Einstandspreis index: average_cost: Durchschnittlicher Einstandspreis holdings: Positionen @@ -23,13 +51,35 @@ de: avg_cost_label: Durchschnittlicher Einstandspreis current_market_price_label: Aktueller Marktpreis delete: Löschen - delete_subtitle: Dadurch wird die Position und alle zugehörigen Trades auf diesem Konto gelöscht Diese Aktion kann nicht rückgängig gemacht werden + delete_subtitle: Dadurch wird die Position und alle zugehörigen Trades auf diesem Konto gelöscht. Diese Aktion kann nicht rückgängig gemacht werden. delete_title: Position löschen + edit_security: Wertpapier bearbeiten history: Verlauf + no_trade_history: Für diese Position ist keine Trade-Historie verfügbar. overview: Übersicht portfolio_weight_label: Portfolio-Gewichtung settings: Einstellungen + security_label: Wertpapier + originally: "war %{ticker}" + search_security: Wertpapier suchen + search_security_placeholder: Nach Ticker oder Name suchen + cancel: Abbrechen + remap_security: Speichern + no_security_provider: Wertpapier-Provider nicht konfiguriert. Suche nach Wertpapieren nicht möglich. + security_remapped_label: Wertpapier umgebildet + provider_sent: "Provider: %{ticker}" + reset_to_provider: Auf Provider zurücksetzen + reset_confirm_title: Wertpapier auf Provider zurücksetzen? + reset_confirm_body: "Das Wertpapier wird von %{current} zurück auf %{original} geändert; alle zugehörigen Trades werden verschoben." ticker_label: Ticker trade_history_entry: "%{qty} Anteile von %{security} zu %{price}" total_return_label: Gesamtrendite + shares_label: Anteile + book_value_label: Buchwert + market_value_label: Marktwert unknown: Unbekannt + cost_basis_locked_label: Einstandspreis ist gesperrt + cost_basis_locked_description: Ihr manuell gesetzter Einstandspreis wird durch Syncs nicht geändert. + unlock_cost_basis: Freigeben + unlock_confirm_title: Einstandspreis freigeben? + unlock_confirm_body: Der Einstandspreis kann dann durch Provider-Syncs oder Trade-Berechnungen aktualisiert werden. diff --git a/config/locales/views/holdings/en.yml b/config/locales/views/holdings/en.yml index a14efe9b6..4fe40c9e6 100644 --- a/config/locales/views/holdings/en.yml +++ b/config/locales/views/holdings/en.yml @@ -15,6 +15,10 @@ en: security_not_found: Could not find the selected security. reset_security: success: Security reset to provider value. + sync_prices: + success: Market data synced successfully. + unavailable: Market data sync is not available for offline securities. + provider_error: Could not fetch latest prices. Please try again in a few minutes. errors: security_collision: "Cannot remap: you already have a holding for %{ticker} on %{date}." cost_basis_sources: @@ -82,3 +86,11 @@ en: unlock_cost_basis: Unlock unlock_confirm_title: Unlock cost basis? unlock_confirm_body: This will allow the cost basis to be updated by provider syncs or trade calculations. + shares_label: Shares + book_value_label: Book Value + market_value_label: Market Value + market_data_label: Market data + market_data_sync_button: Refresh + last_price_update: Last price update + syncing: Syncing... + never: Never diff --git a/config/locales/views/holdings/es.yml b/config/locales/views/holdings/es.yml index 0db0f3010..9ebbce072 100644 --- a/config/locales/views/holdings/es.yml +++ b/config/locales/views/holdings/es.yml @@ -5,9 +5,37 @@ es: brokerage_cash: Efectivo en la cuenta de corretaje destroy: success: Posición eliminada + update: + success: Base de costes guardada. + error: Valor de base de costes no válido. + unlock_cost_basis: + success: Base de costes desbloqueada. Podría actualizarse en la próxima sincronización. + remap_security: + success: Valor actualizado correctamente. + security_not_found: No se ha podido encontrar el valor seleccionado. + reset_security: + success: Valor restablecido al valor del proveedor. + errors: + security_collision: "No se puede reasignar: ya tienes una posición para %{ticker} en la fecha %{date}." + cost_basis_sources: + manual: Configurado por el usuario + calculated: Desde operaciones + provider: Desde el proveedor + cost_basis_cell: + unknown: "--" + set_cost_basis_header: "Establecer base de costes para %{ticker} (%{qty} acciones)" + total_cost_basis_label: Base de costes total + or_per_share_label: "O introduce por acción:" + per_share: por acción + cancel: Cancelar + save: Guardar + overwrite_confirm_title: ¿Sobrescribir base de costes? + overwrite_confirm_body: "Esto reemplazará la base de costes actual de %{current}." holding: per_share: por acción shares: "%{qty} acciones" + unknown: "--" + no_cost_basis: Sin base de costes index: average_cost: Costo promedio holdings: Posiciones @@ -17,21 +45,50 @@ es: return: Rendimiento total weight: Peso missing_price_tooltip: - description: Esta inversión tiene valores faltantes y no pudimos calcular - su rendimiento o valor. + description: Esta inversión tiene valores faltantes y no pudimos calcular su rendimiento o valor. missing_data: Datos faltantes + sync_prices: + success: Datos de mercado sincronizados correctamente. + unavailable: La sincronización de datos de mercado no está disponible para valores fuera de línea. + provider_error: No se pudieron obtener los precios más recientes. Inténtalo de nuevo en unos minutos. show: avg_cost_label: Costo promedio current_market_price_label: Precio de mercado actual delete: Eliminar - delete_subtitle: Esto eliminará la posición y todas tus operaciones asociadas - en esta cuenta. Esta acción no se puede deshacer. + delete_subtitle: Esto eliminará la posición y todas tus operaciones asociadas en esta cuenta. Esta acción no se puede deshacer. delete_title: Eliminar posición + edit_security: Editar valor history: Historial + no_trade_history: No hay historial de operaciones disponible para esta posición. overview: Resumen portfolio_weight_label: Peso en el portafolio settings: Configuración + security_label: Valor + originally: "era %{ticker}" + search_security: Buscar valor + search_security_placeholder: Buscar por ticker o nombre + cancel: Cancelar + remap_security: Guardar + no_security_provider: Proveedor de valores no configurado. No se pueden buscar valores. + security_remapped_label: Valor reasignado + provider_sent: "El proveedor envió: %{ticker}" + reset_to_provider: Restablecer al proveedor + reset_confirm_title: ¿Restablecer valor al del proveedor? + reset_confirm_body: "Esto cambiará el valor de %{current} de nuevo a %{original} y moverá todas las operaciones asociadas." ticker_label: Ticker trade_history_entry: "%{qty} acciones de %{security} a %{price}" total_return_label: Rendimiento total + shares_label: Acciones + book_value_label: Valor en libros + market_value_label: Valor de mercado + market_data_label: Datos de mercado + market_data_sync_button: Actualizar + last_price_update: Última actualización de precio + syncing: Sincronizando... + never: Nunca unknown: Desconocido + cost_basis_locked_label: La base de costes está bloqueada + cost_basis_locked_description: La base de costes establecida manualmente no cambiará con las sincronizaciones. + unlock_cost_basis: Desbloquear + unlock_confirm_title: ¿Desbloquear base de costes? + unlock_confirm_body: Esto permitirá que la base de costes se actualice mediante las sincronizaciones del proveedor o los cálculos de operaciones. \ No newline at end of file diff --git a/config/locales/views/holdings/fr.yml b/config/locales/views/holdings/fr.yml index 09c96ef83..2959132a1 100644 --- a/config/locales/views/holdings/fr.yml +++ b/config/locales/views/holdings/fr.yml @@ -33,4 +33,7 @@ fr: ticker_label: Ticker trade_history_entry: "%{qty} actions de %{security} à %{price}" total_return_label: Rendement total + shares_label: Actions + book_value_label: Valeur comptable + market_value_label: Valeur marchande unknown: Inconnu diff --git a/config/locales/views/holdings/pl.yml b/config/locales/views/holdings/pl.yml new file mode 100644 index 000000000..c9e364c13 --- /dev/null +++ b/config/locales/views/holdings/pl.yml @@ -0,0 +1,94 @@ +--- +pl: + holdings: + cash: + brokerage_cash: Gotówka maklerska + destroy: + success: Pozycja została usunięta + update: + success: Koszt bazowy został zapisany. + error: Nieprawidłowa wartość kosztu bazowego. + unlock_cost_basis: + success: Odblokowano koszt bazowy. Może zostać zaktualizowany przy następnej synchronizacji. + remap_security: + success: Papier wartościowy został pomyślnie zaktualizowany. + security_not_found: Nie udało się znaleźć wybranego papieru wartościowego. + reset_security: + success: Zresetowano papier wartościowy do wartości od dostawcy. + sync_prices: + success: Dane rynkowe zostały pomyślnie zsynchronizowane. + unavailable: Synchronizacja danych rynkowych nie jest dostępna dla papierów offline. + provider_error: Nie udało się pobrać najnowszych cen. Spróbuj ponownie za kilka minut. + errors: + security_collision: 'Nie można zmienić mapowania: masz już pozycję dla %{ticker} z dnia %{date}.' + cost_basis_sources: + manual: Ustawione przez użytkownika + calculated: Z transakcji giełdowych + provider: Od dostawcy + cost_basis_cell: + unknown: "--" + set_cost_basis_header: Ustaw koszt bazowy dla %{ticker} (%{qty} akcji) + total_cost_basis_label: Łączny koszt bazowy + or_per_share_label: 'Lub wpisz na akcję:' + per_share: na akcję + cancel: Anuluj + save: Zapisz + overwrite_confirm_title: Nadpisać koszt bazowy? + overwrite_confirm_body: Spowoduje to zastąpienie aktualnego kosztu bazowego wynoszącego %{current}. + holding: + per_share: na akcję + shares: "%{qty} akcji" + unknown: "--" + no_cost_basis: Brak kosztu bazowego + index: + average_cost: Średni koszt + holdings: Pozycje + name: Nazwa + new_holding: Nowa aktywność + no_holdings: Brak pozycji do wyświetlenia. + return: Łączny zwrot + weight: Udział + missing_price_tooltip: + description: Ta inwestycja ma brakujące wartości i nie mogliśmy obliczyć jej zwrotów ani wartości. + missing_data: Brakujące dane + show: + avg_cost_label: Średni koszt + current_market_price_label: Aktualna cena rynkowa + delete: Usuń + delete_subtitle: Spowoduje to usunięcie pozycji i wszystkich powiązanych transakcji na tym koncie. Tej akcji nie można cofnąć. + delete_title: Usuń pozycję + edit_security: Edytuj papier wartościowy + history: Historia + no_trade_history: Brak historii transakcji dla tej pozycji. + overview: Przegląd + portfolio_weight_label: Udział w portfelu + settings: Ustawienia + security_label: Papier wartościowy + originally: wcześniej %{ticker} + search_security: Wyszukaj papier wartościowy + search_security_placeholder: Szukaj po tickerze lub nazwie + cancel: Anuluj + remap_security: Zapisz + no_security_provider: Dostawca papierów wartościowych nie jest skonfigurowany. Nie można wyszukiwać papierów wartościowych. + security_remapped_label: Papier wartościowy zmieniony + provider_sent: 'Dostawca przesłał: %{ticker}' + reset_to_provider: Przywróć wartość od dostawcy + reset_confirm_title: Przywrócić papier wartościowy do wartości od dostawcy? + reset_confirm_body: Spowoduje to zmianę papieru wartościowego z %{current} z powrotem na %{original} i przeniesienie wszystkich powiązanych transakcji. + ticker_label: Symbol ticker + trade_history_entry: "%{qty} akcji %{security} po %{price}" + total_return_label: Łączny zwrot + unknown: Nieznane + cost_basis_locked_label: Podstawa kosztowa jest zablokowana + cost_basis_locked_description: Ręcznie ustawiona podstawa kosztowa nie będzie zmieniana przez synchronizacje. + unlock_cost_basis: Odblokuj + unlock_confirm_title: Odblokować podstawę kosztową? + unlock_confirm_body: To pozwoli aktualizować podstawę kosztową przez synchronizacje dostawcy lub obliczenia transakcji. + shares_label: Akcje + book_value_label: Wartość księgowa + market_value_label: Wartość rynkowa + market_data_label: Dane rynkowe + market_data_sync_button: Odśwież + last_price_update: Ostatnia aktualizacja ceny + syncing: Synchronizacja... + never: Nigdy diff --git a/config/locales/views/impersonation_sessions/pl.yml b/config/locales/views/impersonation_sessions/pl.yml new file mode 100644 index 000000000..3bc49a92b --- /dev/null +++ b/config/locales/views/impersonation_sessions/pl.yml @@ -0,0 +1,15 @@ +--- +pl: + impersonation_sessions: + approve: + success: Wniosek został zatwierdzony + complete: + success: Sesja została zakończona + create: + success: Wniosek został wysłany do użytkownika. Oczekiwanie na zatwierdzenie. + join: + success: Dołączono do sesji + leave: + success: Opuszczono sesję + reject: + success: Wniosek został odrzucony diff --git a/config/locales/views/imports/ca.yml b/config/locales/views/imports/ca.yml index b6f188dd7..8de803977 100644 --- a/config/locales/views/imports/ca.yml +++ b/config/locales/views/imports/ca.yml @@ -110,3 +110,8 @@ ca: description: Aquí tens un resum dels nous elements que s'afegiran al teu compte un cop publiquis aquesta importació. title: Confirma les teves dades d'importació + summary_item_label: Element + summary_count_label: Quantitat + empty_summary: No s'han trobat registres importables en aquest fitxer. Pot estar buit, o les línies no coincideixen amb el format d'exportació esperat (cada línia ha de ser un objecte JSON amb les claus «type» i «data», amb tipus admesos per aquesta importació). + publish_import: Publicar la importació + back_to_imports: Tornar a les importacions diff --git a/config/locales/views/imports/de.yml b/config/locales/views/imports/de.yml index ce94b1a0b..faab67cc0 100644 --- a/config/locales/views/imports/de.yml +++ b/config/locales/views/imports/de.yml @@ -1,6 +1,30 @@ --- de: import: + qif_category_selections: + show: + title: "Konfigurieren und auswählen" + description: "Überprüfe das erkannte Datumsformat und wähle dann die Kategorien und Tags aus deiner QIF-Datei aus, die in %{product_name} importiert werden sollen." + categories_heading: Kategorien + categories_found: + one: "1 Kategorie gefunden" + other: "%{count} Kategorien gefunden" + category_name_col: Kategoriename + transactions_col: Buchungen + tags_heading: Tags + tags_found: + one: "1 Tag gefunden" + other: "%{count} Tags gefunden" + tag_name_col: Tag-Name + txn_count: + one: "1 Buchung" + other: "%{count} Buchungen" + empty_state_primary: In dieser QIF-Datei wurden keine Kategorien oder Tags gefunden. + empty_state_secondary: Alle Transaktionen werden ohne Kategorien und Tags importiert. + submit: Weiter zur Überprüfung + split_warning_title: Aufgeteilte Buchungen erkannt + split_warning_description: "Diese QIF-Datei enthält aufgeteilte Buchungen. Aufgeteilte Buchungen werden noch nicht unterstützt – jede aufgeteilte Buchung wird als einzelne Buchung mit ihrem Gesamtbetrag und ohne Kategorie importiert. Die einzelnen Aufteilungsdetails werden nicht übernommen." + split_badge: aufgeteilt cleans: show: description: Bearbeite deine Daten in der Tabelle unten. Rote Zellen sind ungültig. @@ -8,8 +32,18 @@ de: errors_notice_mobile: Deine Daten enthalten Fehler. Tippe auf den Fehler-Tooltip, um Details zu sehen. title: Daten bereinigen configurations: + update: + success: Import wurde erfolgreich konfiguriert. + category_import: + button_label: Weiter + description: Lade eine einfache CSV-Datei hoch (z. B. wie bei einem Export). Die Spalten werden automatisch zugeordnet. + instructions: Wähle Weiter, um die CSV zu parsen und zum Bereinigungsschritt zu gelangen. mint_import: date_format_label: Datumsformat + rule_import: + description: Konfigurieren Sie den Regel-Import. Regeln werden basierend auf den CSV-Daten erstellt oder aktualisiert. + process_button: Regeln verarbeiten + process_help: Klicken Sie unten, um Ihre CSV zu verarbeiten und Regelzeilen zu erzeugen. show: description: Wähle die Spalten aus, die den jeweiligen Feldern in deiner CSV entsprechen. title: Import konfigurieren @@ -17,6 +51,7 @@ de: date_format_label: Datumsformat transaction_import: date_format_label: Datumsformat + rows_to_skip_label: Erste n Zeilen überspringen confirms: mappings: create_account: Konto erstellen @@ -36,6 +71,15 @@ de: tag_mapping_title: Tags zuweisen uploads: show: + qif_title: QIF-Datei hochladen + qif_description: Wähle das Konto, zu dem diese QIF-Datei gehört, und lade deinen .qif-Export aus Quicken hoch. + qif_account_label: Konto + qif_account_placeholder: Konto auswählen… + qif_file_prompt: um deine QIF-Datei hier hinzuzufügen + qif_file_hint: Nur .qif-Dateien + qif_submit: QIF hochladen + browse: Durchsuchen + csv_file_prompt: um deine CSV-Datei hier hinzuzufügen description: Füge unten deine CSV-Datei ein oder lade sie hoch. Bitte lies die Anweisungen in der Tabelle unten, bevor du beginnst. instructions_1: Unten siehst du ein Beispiel einer CSV-Datei mit verfügbaren Spalten für den Import. instructions_2: Deine CSV muss eine Kopfzeile enthalten. @@ -44,6 +88,19 @@ de: instructions_5: Keine Kommas, Währungssymbole oder Klammern in Zahlen verwenden. title: Daten importieren imports: + date_format: + heading: Datumsformat + description: "Das Datumsformat wurde automatisch aus deiner Datei erkannt. Ändere es, wenn die Datumsangaben falsch aussehen." + preview: "Erstes erkanntes Datum" + error_title: "Datumsformat konnte nicht erkannt werden" + error_description: "Keines der unterstützten Datumsformate konnte die Datumsangaben in dieser Datei lesen. Bitte überprüfe, ob die Datei gültige Datumseinträge enthält." + steps: + upload: Hochladen + configure: Konfigurieren + clean: Bereinigen + map: Zuordnen + confirm: Bestätigen + select: Auswählen index: title: Importe new: Neuer Import @@ -71,12 +128,67 @@ de: new: description: Du kannst verschiedene Datentypen manuell über CSV importieren oder eine unserer Importvorlagen wie Mint verwenden. import_accounts: Konten importieren + import_categories: Kategorien importieren + import_file: Dokument importieren + import_file_description: KI-gestützte Analyse für PDFs und durchsuchbarer Upload für weitere Formate import_mint: Von Mint importieren import_portfolio: Investitionen importieren + import_rules: Regeln importieren import_transactions: Transaktionen importieren + import_qif: Von Quicken importieren (QIF) + requires_account: Importiere zuerst Konten, um diese Option zu nutzen. resume: "%{type} fortsetzen" sources: Quellen title: Neuer CSV-Import + create: + file_too_large: Datei ist zu groß. Maximale Größe %{max_size} MB. + invalid_file_type: Ungültiger Dateityp. Bitte laden Sie eine CSV-Datei hoch. + csv_uploaded: CSV wurde erfolgreich hochgeladen. + pdf_too_large: PDF ist zu groß. Maximale Größe %{max_size} MB. + pdf_processing: Ihre PDF wird verarbeitet. Sie erhalten eine E-Mail, wenn die Analyse abgeschlossen ist. + invalid_pdf: Die hochgeladene Datei ist keine gültige PDF. + document_too_large: Dokument ist zu groß. Maximale Größe %{max_size} MB. + invalid_document_file_type: Ungültiger Dokumenttyp für den aktiven Vektorspeicher. + document_uploaded: Dokument wurde erfolgreich hochgeladen. + document_upload_failed: Das Dokument konnte nicht in den Vektorspeicher hochgeladen werden. Bitte versuchen Sie es erneut. + document_provider_not_configured: Kein Vektorspeicher für Dokument-Uploads konfiguriert. + show: + finalize_upload: Bitte schließen Sie den Datei-Upload ab. + finalize_mappings: Bitte schließen Sie die Zuordnungen ab, bevor Sie fortfahren. + errors: + custom_column_requires_inflow: "Bei benutzerdefinierten Spalten muss eine Einnahmen-Spalte ausgewählt werden." + document_types: + bank_statement: Kontoauszug + credit_card_statement: Kreditkartenabrechnung + investment_statement: Wertpapierabrechnung + financial_document: Finanzdokument + contract: Vertrag + other: Sonstiges Dokument + unknown: Unbekanntes Dokument + pdf_import: + processing_title: Ihre PDF wird verarbeitet + processing_description: Wir analysieren Ihr Dokument mit KI. Das kann einen Moment dauern. Sie erhalten eine E-Mail, wenn die Analyse abgeschlossen ist. + check_status: Status prüfen + back_to_dashboard: Zurück zur Übersicht + failed_title: Verarbeitung fehlgeschlagen + failed_description: Ihre PDF konnte nicht verarbeitet werden. Bitte versuchen Sie es erneut oder kontaktieren Sie den Support. + try_again: Erneut versuchen + delete_import: Import löschen + complete_title: Dokument analysiert + complete_description: Wir haben Ihre PDF analysiert – hier ist das Ergebnis. + document_type_label: Dokumenttyp + summary_label: Zusammenfassung + email_sent_notice: Sie haben eine E-Mail mit den nächsten Schritten erhalten. + back_to_imports: Zurück zu Importen + unknown_state_title: Unbekannter Status + unknown_state_description: Dieser Import befindet sich in einem unerwarteten Zustand. Bitte kehren Sie zu den Importen zurück. + processing_failed_with_message: "%{message}" + processing_failed_generic: "Verarbeitung fehlgeschlagen: %{error}" ready: description: Hier ist eine Zusammenfassung der neuen Elemente, die deinem Konto hinzugefügt werden, sobald du diesen Import veröffentlichst. title: Importdaten bestätigen + summary_item_label: Eintrag + summary_count_label: Anzahl + empty_summary: In dieser Datei wurden keine importierbaren Datensätze gefunden. Sie ist möglicherweise leer, oder die Zeilen entsprechen nicht dem erwarteten Exportformat (jede Zeile sollte ein JSON-Objekt mit den Schlüsseln „type“ und „data“ und unterstützten Typen sein). + publish_import: Import veröffentlichen + back_to_imports: Zurück zu Importen diff --git a/config/locales/views/imports/en.yml b/config/locales/views/imports/en.yml index 6be06ff26..9f41ab712 100644 --- a/config/locales/views/imports/en.yml +++ b/config/locales/views/imports/en.yml @@ -1,6 +1,30 @@ --- en: import: + qif_category_selections: + show: + title: "Configure & select" + description: "Review the detected date format, then choose which categories and tags from your QIF file to bring into %{product_name}." + categories_heading: Categories + categories_found: + one: "1 category found" + other: "%{count} categories found" + category_name_col: Category name + transactions_col: Transactions + tags_heading: Tags + tags_found: + one: "1 tag found" + other: "%{count} tags found" + tag_name_col: Tag name + txn_count: + one: "1 txn" + other: "%{count} txns" + split_warning_title: Split transactions detected + split_warning_description: "This QIF file contains split transactions. Splits are not yet supported, so each split transaction will be imported as a single transaction with its full amount and no category. The individual split breakdowns will not be preserved." + split_badge: split + empty_state_primary: No categories or tags were found in this QIF file. + empty_state_secondary: All transactions will be imported without categories or tags. + submit: Continue to review cleans: show: description: Edit your data in the table below. Red cells are invalid. @@ -33,6 +57,13 @@ en: date_format_label: Date format rows_to_skip_label: Skip first n rows confirms: + sure_import: + title: Confirm your import + description: Review the data that will be imported from your export file. + summary: Import summary + empty_summary: We could not find any importable records in this file. It may be empty, or the lines may not match the expected export format (each line should be a JSON object with "type" and "data" keys, using types this import supports). + publish_button: Start import + cancel: Cancel mappings: create_account: Create account csv_mapping_label: "%{mapping} in CSV" @@ -59,6 +90,15 @@ en: tag_mapping_title: Assign your tags uploads: show: + qif_title: Upload QIF file + qif_description: Select the account this QIF file belongs to, then upload your .qif export from Quicken. + qif_account_label: Account + qif_account_placeholder: Select an account… + qif_file_prompt: to add your QIF file here + qif_file_hint: .qif files only + qif_submit: Upload QIF + browse: Browse + csv_file_prompt: to add your CSV file here description: Paste or upload your CSV file below. Please review the instructions in the table below before beginning. instructions_1: Below is an example CSV with columns available for import. @@ -68,7 +108,41 @@ en: instructions_4: Columns marked with an asterisk (*) are required data. instructions_5: No commas, no currency symbols, and no parentheses in numbers. title: Import your data + sure_import: + title: Import from export + description: Upload the all.ndjson file from your data export to restore your accounts, transactions, categories, and more. + drop_title: Drop NDJSON to upload + drop_subtitle: Your file will be uploaded automatically + browse: Browse + browse_hint: to add your all.ndjson file here + upload_button: Upload NDJSON + hint_html: Upload the all.ndjson file from your data export ZIP + ndjson_invalid: Must be valid NDJSON with at least one record imports: + date_format: + heading: Date format + description: "The date format was auto-detected from your file. Change it if dates look incorrect." + preview: "First parsed date" + error_title: "Unable to detect date format" + error_description: "None of the supported date formats could parse the dates in this file. Please check that the file contains valid date entries." + type_labels: + transaction_import: "Transaction import" + trade_import: "Trade import" + account_import: "Account import" + mint_import: "Mint import" + qif_import: "QIF import" + category_import: "Category import" + rule_import: "Rule import" + pdf_import: "PDF import" + document_import: "Document import" + sure_import: "Sure import" + steps: + upload: Upload + configure: Configure + clean: Clean + map: Map + confirm: Confirm + select: Select index: title: Imports new: New Import @@ -80,6 +154,17 @@ en: status: Status actions: Actions row: + type_labels: + transaction_import: "Transaction" + trade_import: "Trade" + account_import: "Account" + mint_import: "Mint" + qif_import: "QIF" + category_import: "Category" + rule_import: "Rule" + pdf_import: "PDF" + document_import: "Document" + sure_import: "Sure" status: in_progress: In progress uploading: Processing rows @@ -94,16 +179,22 @@ en: view: View empty: No imports yet. new: - description: You can manually import various types of data via CSV or use one - of our import templates like Mint. + description: Import from a financial tool or upload raw data files. + tab_financial_tools: Financial Tools & Files + tab_raw_data: Raw Data + coming_soon: Coming soon + import_ynab: Import from YNAB import_accounts: Import accounts import_categories: Import categories import_mint: Import from Mint import_portfolio: Import investments import_rules: Import rules import_transactions: Import transactions + import_qif: Import from Quicken (QIF) + import_sure: Import from Sure + import_sure_description: Full-export .ndjson file import_file: Import document - import_file_description: AI-powered analysis for PDFs and searchable upload for other supported files + import_file_description: AI-powered analysis for PDFs and searchable file upload requires_account: Import accounts first to unlock this option. resume: Resume %{type} sources: Sources @@ -112,6 +203,7 @@ en: file_too_large: File is too large. Maximum size is %{max_size}MB. invalid_file_type: Invalid file type. Please upload a CSV file. csv_uploaded: CSV uploaded successfully. + ndjson_uploaded: NDJSON file uploaded successfully. pdf_too_large: PDF file is too large. Maximum size is %{max_size}MB. pdf_processing: Your PDF is being processed. You will receive an email when analysis is complete. invalid_pdf: The uploaded file is not a valid PDF. @@ -119,6 +211,8 @@ en: invalid_document_file_type: Invalid document file type for the active vector store. document_uploaded: Document uploaded successfully. document_upload_failed: We couldn't upload the document to the vector store. Please try again. + invalid_ndjson_file_type: Invalid file type or format. Please upload a valid .ndjson or .json export file. + ndjson_uploaded: NDJSON file uploaded successfully. document_provider_not_configured: No vector store is configured for document uploads. show: finalize_upload: Please finalize your file upload. @@ -127,6 +221,11 @@ en: description: Here's a summary of the new items that will be added to your account once you publish this import. title: Confirm your import data + summary_item_label: Item + summary_count_label: Count + empty_summary: We could not find any importable records in this file. It may be empty, or the lines may not match the expected export format (each line should be a JSON object with "type" and "data" keys, using types this import supports). + publish_import: Publish import + back_to_imports: Back to imports errors: custom_column_requires_inflow: "Custom column imports require an inflow column to be selected" document_types: diff --git a/config/locales/views/imports/es.yml b/config/locales/views/imports/es.yml index 8674ebcae..b8f0c9ca5 100644 --- a/config/locales/views/imports/es.yml +++ b/config/locales/views/imports/es.yml @@ -1,6 +1,30 @@ --- es: import: + qif_category_selections: + show: + title: "Configurar y seleccionar" + description: "Revisa el formato de fecha detectado y luego elige qué categorías y etiquetas de tu archivo QIF importar en %{product_name}." + categories_heading: Categorías + categories_found: + one: "1 categoría encontrada" + other: "%{count} categorías encontradas" + category_name_col: Nombre de categoría + transactions_col: Transacciones + tags_heading: Etiquetas + tags_found: + one: "1 etiqueta encontrada" + other: "%{count} etiquetas encontradas" + tag_name_col: Nombre de etiqueta + txn_count: + one: "1 transacción" + other: "%{count} transacciones" + empty_state_primary: No se encontraron categorías ni etiquetas en este archivo QIF. + empty_state_secondary: Todas las transacciones se importarán sin categorías ni etiquetas. + submit: Continuar a la revisión + split_warning_title: Transacciones divididas detectadas + split_warning_description: "Este archivo QIF contiene transacciones divididas. Las divisiones aún no son compatibles, por lo que cada transacción dividida se importará como una única transacción con su importe total y sin categoría. Los desgloses individuales de las divisiones no se conservarán." + split_badge: dividida cleans: show: description: Edita tus datos en la tabla de abajo. Las celdas rojas son inválidas. @@ -8,8 +32,18 @@ es: errors_notice_mobile: Tienes errores en tus datos. Toca el tooltip del error para ver los detalles. title: Limpia tus datos configurations: + update: + success: Importación configurada correctamente. + category_import: + button_label: Continuar + description: Sube un archivo CSV sencillo (como el que generamos al exportar tus datos). Mapearemos las columnas automáticamente. + instructions: Selecciona continuar para analizar tu CSV y pasar al paso de limpieza. mint_import: date_format_label: Formato de fecha + rule_import: + description: Configura tu importación de reglas. Las reglas se crearán o actualizarán basándose en los datos del CSV. + process_button: Procesar reglas + process_help: Haz clic en el botón de abajo para procesar tu CSV y generar las filas de reglas. show: description: Selecciona las columnas que corresponden a cada campo en tu CSV. title: Configura tu importación @@ -17,29 +51,35 @@ es: date_format_label: Formato de fecha transaction_import: date_format_label: Formato de fecha + rows_to_skip_label: Omitir las primeras n filas confirms: mappings: create_account: Crear cuenta csv_mapping_label: "%{mapping} en CSV" sure_mapping_label: "%{mapping} en %{product_name}" - no_accounts: Aún no tienes cuentas. Por favor, crea una cuenta que podamos usar para las filas - (sin asignar) en tu CSV o vuelve al paso de Limpieza y proporciona un nombre de cuenta que podamos usar. + no_accounts: Aún no tienes cuentas. Por favor, crea una cuenta que podamos usar para las filas (sin asignar) en tu CSV o vuelve al paso de Limpieza y proporciona un nombre de cuenta que podamos usar. rows_label: Filas unassigned_account: ¿Necesitas crear una nueva cuenta para las filas sin asignar? show: - account_mapping_description: Asigna todas las cuentas de tu archivo importado a las cuentas existentes de Sure. - También puedes añadir nuevas cuentas o dejarlas sin categorizar. + account_mapping_description: Asigna todas las cuentas de tu archivo importado a las cuentas existentes de Sure. También puedes añadir nuevas cuentas o dejarlas sin categorizar. account_mapping_title: Asigna tus cuentas account_type_mapping_description: Asigna todos los tipos de cuenta de tu archivo importado a los de Sure. account_type_mapping_title: Asigna tus tipos de cuenta - category_mapping_description: Asigna todas las categorías de tu archivo importado a las categorías existentes de Sure. - También puedes añadir nuevas categorías o dejarlas sin categorizar. + category_mapping_description: Asigna todas las categorías de tu archivo importado a las categorías existentes de Sure. También puedes añadir nuevas categorías o dejarlas sin categorizar. category_mapping_title: Asigna tus categorías - tag_mapping_description: Asigna todas las etiquetas de tu archivo importado a las etiquetas existentes de Sure. - También puedes añadir nuevas etiquetas o dejarlas sin categorizar. + tag_mapping_description: Asigna todas las etiquetas de tu archivo importado a las etiquetas existentes de Sure. También puedes añadir nuevas etiquetas o dejarlas sin categorizar. tag_mapping_title: Asigna tus etiquetas uploads: show: + qif_title: Subir archivo QIF + qif_description: Selecciona la cuenta a la que pertenece este archivo QIF y sube tu exportación .qif desde Quicken. + qif_account_label: Cuenta + qif_account_placeholder: Seleccionar una cuenta… + qif_file_prompt: para añadir tu archivo QIF aquí + qif_file_hint: Solo archivos .qif + qif_submit: Subir QIF + browse: Examinar + csv_file_prompt: para añadir tu archivo CSV aquí description: Pega o sube tu archivo CSV abajo. Por favor, revisa las instrucciones en la tabla de abajo antes de comenzar. instructions_1: Abajo hay un ejemplo de CSV con columnas disponibles para importar. instructions_2: Tu CSV debe tener una fila de encabezado. @@ -48,14 +88,27 @@ es: instructions_5: Sin comas, sin símbolos de moneda y sin paréntesis en los números. title: Importa tus datos imports: + date_format: + heading: Formato de fecha + description: "El formato de fecha se detectó automáticamente desde tu archivo. Cámbialo si las fechas parecen incorrectas." + preview: "Primera fecha analizada" + error_title: "No se puede detectar el formato de fecha" + error_description: "Ninguno de los formatos de fecha compatibles pudo analizar las fechas de este archivo. Por favor, comprueba que el archivo contiene entradas de fecha válidas." + steps: + upload: Subir + configure: Configurar + clean: Limpiar + map: Mapear + confirm: Confirmar + select: Seleccionar index: title: Importaciones new: Nueva importación table: - title: Imports + title: Importaciones header: date: Fecha - operation: Operation + operation: Operación status: Estado actions: Acciones row: @@ -68,19 +121,74 @@ es: failed: Fallido actions: revert: Revertir - confirm_revert: Esto eliminará las transacciones que se importaron, pero aún podrá revisar y volver a importar sus datos en cualquier momento. + confirm_revert: Esto eliminará las transacciones que se importaron, pero aún podrás revisar y volver a importar tus datos en cualquier momento. delete: Eliminar view: Ver empty: Aún no hay importaciones. new: description: Puedes importar manualmente varios tipos de datos mediante CSV o usar una de nuestras plantillas de importación como Mint. import_accounts: Importar cuentas + import_categories: Importar categorías import_mint: Importar desde Mint import_portfolio: Importar inversiones + import_rules: Importar reglas import_transactions: Importar transacciones + import_qif: Importar desde Quicken (QIF) + import_file: Importar documento + import_file_description: Análisis potenciado por IA para PDFs y subida con búsqueda para otros archivos compatibles + requires_account: Importa cuentas primero para desbloquear esta opción. resume: Reanudar %{type} sources: Fuentes - title: Nueva importación CSV + title: Nueva importación + create: + file_too_large: El archivo es demasiado grande. El tamaño máximo es %{max_size}MB. + invalid_file_type: Tipo de archivo no válido. Por favor, sube un archivo CSV. + csv_uploaded: CSV subido correctamente. + pdf_too_large: El archivo PDF es demasiado grande. El tamaño máximo es %{max_size}MB. + pdf_processing: Tu PDF se está procesando. Recibirás un correo electrónico cuando el análisis haya finalizado. + invalid_pdf: El archivo subido no es un PDF válido. + document_too_large: El documento es demasiado grande. El tamaño máximo es %{max_size}MB. + invalid_document_file_type: Tipo de archivo de documento no válido para el almacén de vectores activo. + document_uploaded: Documento subido correctamente. + document_upload_failed: No hemos podido subir el documento al almacén de vectores. Por favor, inténtalo de nuevo. + document_provider_not_configured: No hay ningún almacén de vectores configurado para la subida de documentos. + show: + finalize_upload: Por favor, finaliza la subida de tu archivo. + finalize_mappings: Por favor, finaliza tus mapeos antes de continuar. ready: description: Aquí tienes un resumen de los nuevos elementos que se añadirán a tu cuenta una vez publiques esta importación. title: Confirma tus datos de importación + summary_item_label: Elemento + summary_count_label: Cantidad + empty_summary: No se han encontrado registros importables en este archivo. Puede estar vacío, o las líneas no coinciden con el formato de exportación esperado (cada línea debe ser un objeto JSON con las claves «type» y «data», usando tipos que admite esta importación). + publish_import: Publicar importación + back_to_imports: Volver a importaciones + errors: + custom_column_requires_inflow: "Las importaciones de columnas personalizadas requieren que se seleccione una columna de entrada de fondos (inflow)" + document_types: + bank_statement: Extracto bancario + credit_card_statement: Extracto de tarjeta de crédito + investment_statement: Extracto de inversiones + financial_document: Documento financiero + contract: Contrato + other: Otro documento + unknown: Documento desconocido + pdf_import: + processing_title: Procesando tu PDF + processing_description: Estamos analizando tu documento mediante IA. Esto puede tardar un momento. Recibirás un correo electrónico cuando el análisis finalice. + check_status: Comprobar estado + back_to_dashboard: Volver al panel + failed_title: Error en el procesamiento + failed_description: No hemos podido procesar tu documento PDF. Por favor, inténtalo de nuevo o contacta con soporte. + try_again: Reintentar + delete_import: Eliminar importación + complete_title: Documento analizado + complete_description: Hemos analizado tu PDF y esto es lo que hemos encontrado. + document_type_label: Tipo de documento + summary_label: Resumen + email_sent_notice: Se te ha enviado un correo electrónico con los siguientes pasos. + back_to_imports: Volver a importaciones + unknown_state_title: Estado desconocido + unknown_state_description: Esta importación se encuentra en un estado inesperado. Por favor, vuelve a importaciones. + processing_failed_with_message: "%{message}" + processing_failed_generic: "Error en el procesamiento: %{error}" \ No newline at end of file diff --git a/config/locales/views/imports/fr.yml b/config/locales/views/imports/fr.yml index 386ea94d6..4a34d031f 100644 --- a/config/locales/views/imports/fr.yml +++ b/config/locales/views/imports/fr.yml @@ -1,6 +1,30 @@ --- fr: import: + qif_category_selections: + show: + title: "Configurer et sélectionner" + description: "Vérifiez le format de date détecté, puis choisissez les catégories et étiquettes de votre fichier QIF à importer dans %{product_name}." + categories_heading: Catégories + categories_found: + one: "1 catégorie trouvée" + other: "%{count} catégories trouvées" + category_name_col: Nom de la catégorie + transactions_col: Transactions + tags_heading: Étiquettes + tags_found: + one: "1 étiquette trouvée" + other: "%{count} étiquettes trouvées" + tag_name_col: Nom de l'étiquette + txn_count: + one: "1 opération" + other: "%{count} opérations" + empty_state_primary: Aucune catégorie ou étiquette trouvée dans ce fichier QIF. + empty_state_secondary: Toutes les transactions seront importées sans catégories ni étiquettes. + submit: Continuer vers la revue + split_warning_title: Transactions scindées détectées + split_warning_description: "Ce fichier QIF contient des transactions scindées. Les transactions scindées ne sont pas encore prises en charge : chaque transaction scindée sera importée comme une transaction unique avec son montant total et sans catégorie. Les ventilations individuelles ne seront pas conservées." + split_badge: scindée cleans: show: description: Modifiez vos données dans le tableau ci-dessous. Les cellules rouges sont invalides. @@ -29,6 +53,13 @@ fr: date_format_label: Format de date rows_to_skip_label: Ignorer les n premières lignes confirms: + sure_import: + title: Confirmer votre importation + description: Vérifiez les données qui seront importées depuis votre fichier d'export. + summary: Résumé de l'importation + empty_summary: Aucun enregistrement importable n'a été trouvé dans ce fichier. Il est peut-être vide, ou les lignes ne correspondent pas au format d'export attendu (chaque ligne doit être un objet JSON avec les clés « type » et « data », pour des types pris en charge par cet import). + publish_button: Démarrer l'importation + cancel: Annuler mappings: create_account: Créer un compte csv_mapping_label: "%{mapping} dans le CSV" @@ -47,6 +78,15 @@ fr: tag_mapping_title: Attribuez vos étiquettes uploads: show: + qif_title: Téléverser le fichier QIF + qif_description: Sélectionnez le compte auquel appartient ce fichier QIF, puis téléversez votre export .qif depuis Quicken. + qif_account_label: Compte + qif_account_placeholder: Sélectionner un compte… + qif_file_prompt: pour ajouter votre fichier QIF ici + qif_file_hint: Fichiers .qif uniquement + qif_submit: Téléverser le QIF + browse: Parcourir + csv_file_prompt: pour ajouter votre fichier CSV ici description: Collez ou téléversez votre fichier CSV ci-dessous. Veuillez examiner les instructions dans le tableau ci-dessous avant de commencer. instructions_1: Voici un exemple de CSV avec des colonnes disponibles pour l'importation. instructions_2: Votre CSV doit avoir une ligne d'en-tête @@ -54,8 +94,32 @@ fr: instructions_4: Les colonnes marquées avec une étoile (*) sont des données requises. instructions_5: Pas de virgules, pas de symboles monétaires et pas de parenthèses dans les nombres. title: Importez vos données + sure_import: + title: Importer depuis l'export + description: Téléversez le fichier all.ndjson de votre export de données pour restaurer vos comptes, transactions, catégories et plus encore. + drop_title: Déposez le NDJSON pour téléverser + drop_subtitle: Votre fichier sera téléversé automatiquement + browse: Parcourir + browse_hint: pour ajouter votre fichier all.ndjson ici + upload_button: Téléverser le NDJSON + hint_html: Téléversez le fichier all.ndjson de l'archive ZIP d'export de vos données + ndjson_invalid: Le fichier doit être un NDJSON valide avec au moins un enregistrement imports: + date_format: + heading: Format de date + description: "Le format de date a été détecté automatiquement depuis votre fichier. Modifiez-le si les dates semblent incorrectes." + preview: "Première date analysée" + error_title: "Impossible de détecter le format de date" + error_description: "Aucun des formats de date pris en charge n'a pu analyser les dates dans ce fichier. Veuillez vérifier que le fichier contient des entrées de date valides." + steps: + upload: Téléverser + configure: Configurer + clean: Nettoyer + map: Mapper + confirm: Confirmer + select: Sélectionner index: + title: Importations imports: Imports new: Nouvelle importation table: @@ -87,9 +151,62 @@ fr: import_portfolio: Importer les investissements import_rules: Importer les règles import_transactions: Importer les transactions + import_qif: Importer depuis Quicken (QIF) + import_file: Importer un document + import_file_description: Analyse par IA pour les PDFs et téléversement avec recherche pour les autres fichiers pris en charge + requires_account: Importez d'abord des comptes pour débloquer cette option. resume: Reprendre %{type} sources: Sources title: Nouvelle importation CSV + create: + file_too_large: Le fichier est trop volumineux. La taille maximale est de %{max_size} Mo. + invalid_file_type: Type de fichier invalide. Veuillez téléverser un fichier CSV. + csv_uploaded: CSV téléversé avec succès. + pdf_too_large: Le fichier PDF est trop volumineux. La taille maximale est de %{max_size} Mo. + pdf_processing: Votre PDF est en cours de traitement. Vous recevrez un e-mail lorsque l'analyse sera terminée. + invalid_pdf: Le fichier téléversé n'est pas un PDF valide. + document_too_large: Le document est trop volumineux. La taille maximale est de %{max_size} Mo. + invalid_document_file_type: Type de fichier de document invalide pour le magasin de vecteurs actif. + document_uploaded: Document téléversé avec succès. + document_upload_failed: Nous n'avons pas pu téléverser le document dans le magasin de vecteurs. Veuillez réessayer. + document_provider_not_configured: Aucun magasin de vecteurs n'est configuré pour les téléversements de documents. + show: + finalize_upload: Veuillez finaliser le téléversement de votre fichier. + finalize_mappings: Veuillez finaliser vos correspondances avant de continuer. + errors: + custom_column_requires_inflow: "Les importations de colonnes personnalisées nécessitent la sélection d'une colonne d'entrée" + document_types: + bank_statement: Relevé bancaire + credit_card_statement: Relevé de carte de crédit + investment_statement: Relevé d'investissement + financial_document: Document financier + contract: Contrat + other: Autre document + unknown: Document inconnu + pdf_import: + processing_title: Traitement de votre PDF + processing_description: Nous analysons votre document à l'aide de l'IA. Cela peut prendre un moment. Vous recevrez un e-mail lorsque l'analyse sera terminée. + check_status: Vérifier le statut + back_to_dashboard: Retour au tableau de bord + failed_title: Traitement échoué + failed_description: Nous n'avons pas pu traiter votre document PDF. Veuillez réessayer ou contacter le support. + try_again: Réessayer + delete_import: Supprimer l'importation + complete_title: Document analysé + complete_description: Nous avons analysé votre PDF et voici ce que nous avons trouvé. + document_type_label: Type de document + summary_label: Résumé + email_sent_notice: Un e-mail vous a été envoyé avec les prochaines étapes. + back_to_imports: Retour aux importations + unknown_state_title: État inconnu + unknown_state_description: Cette importation est dans un état inattendu. Veuillez retourner aux importations. + processing_failed_with_message: "%{message}" + processing_failed_generic: "Traitement échoué : %{error}" ready: description: Voici un résumé des nouveaux éléments qui seront ajoutés à votre compte une fois que vous aurez publié cette importation. title: Confirmez vos données d'importation + summary_item_label: Élément + summary_count_label: Nombre + empty_summary: Aucun enregistrement importable n'a été trouvé dans ce fichier. Il est peut-être vide, ou les lignes ne correspondent pas au format d'export attendu (chaque ligne doit être un objet JSON avec les clés « type » et « data », pour des types pris en charge par cet import). + publish_import: Publier l'importation + back_to_imports: Retour aux importations diff --git a/config/locales/views/imports/nb.yml b/config/locales/views/imports/nb.yml index b0544f12e..cebd1ddb0 100644 --- a/config/locales/views/imports/nb.yml +++ b/config/locales/views/imports/nb.yml @@ -94,4 +94,9 @@ nb: ready: description: Her er en oppsummering av de nye elementene som vil bli lagt til kontoen din når du publiserer denne importen. - title: Bekreft importdataene dine \ No newline at end of file + title: Bekreft importdataene dine + summary_item_label: Element + summary_count_label: Antall + empty_summary: Vi fant ingen poster som kan importeres i denne filen. Den kan være tom, eller linjene samsvarer ikke med det forventede eksportformatet (hver linje skal være et JSON-objekt med nøklene «type» og «data», med typer denne importen støtter). + publish_import: Publiser import + back_to_imports: Tilbake til importer \ No newline at end of file diff --git a/config/locales/views/imports/nl.yml b/config/locales/views/imports/nl.yml index 898d82c96..31a2d3678 100644 --- a/config/locales/views/imports/nl.yml +++ b/config/locales/views/imports/nl.yml @@ -93,3 +93,8 @@ nl: ready: description: Hier is een samenvatting van de nieuwe items die aan uw account worden toegevoegd zodra u deze import publiceert. title: Uw importgegevens bevestigen + summary_item_label: Item + summary_count_label: Aantal + empty_summary: Er zijn geen importeerbare records in dit bestand gevonden. Het bestand is mogelijk leeg, of de regels voldoen niet aan het verwachte exportformaat (elke regel moet een JSON-object zijn met de sleutels „type“ en „data“, met typen die deze import ondersteunt). + publish_import: Import publiceren + back_to_imports: Terug naar importen diff --git a/config/locales/views/imports/pl.yml b/config/locales/views/imports/pl.yml new file mode 100644 index 000000000..af8db190c --- /dev/null +++ b/config/locales/views/imports/pl.yml @@ -0,0 +1,241 @@ +--- +pl: + import: + qif_category_selections: + show: + title: Wybierz kategorie i tagi + description: Wybierz, które kategorie i tagi z pliku QIF chcesz zaimportować do Sure. Odznaczone elementy zostaną usunięte z tych transakcji. + categories_heading: Kategorie + categories_found: + one: Znaleziono 1 kategorię + few: Znaleziono %{count} kategorie + many: Znaleziono %{count} kategorii + other: Znaleziono %{count} kategorii + category_name_col: Nazwa kategorii + transactions_col: Transakcje + tags_heading: Tagi + tags_found: + one: Znaleziono 1 tag + few: Znaleziono %{count} tagi + many: Znaleziono %{count} tagów + other: Znaleziono %{count} tagów + tag_name_col: Nazwa tagu + txn_count: + one: 1 transakcja + few: "%{count} transakcje" + many: "%{count} transakcji" + other: "%{count} transakcji" + split_warning_title: Wykryto transakcje dzielone + split_warning_description: Ten plik QIF zawiera transakcje dzielone. Podziały nie są jeszcze obsługiwane, więc każda taka transakcja zostanie zaimportowana jako pojedyncza transakcja z pełną kwotą i bez kategorii. Szczegółowy podział nie zostanie zachowany. + split_badge: podział + empty_state_primary: W tym pliku QIF nie znaleziono kategorii ani tagów. + empty_state_secondary: Wszystkie transakcje zostaną zaimportowane bez kategorii i tagów. + submit: Przejdź do podsumowania + cleans: + show: + description: Edytuj swoje dane w tabeli poniżej. Czerwone komórki są nieprawidłowe. + errors_notice: Masz błędy w danych. Najedź na błąd, aby zobaczyć szczegóły. + errors_notice_mobile: Masz błędy w danych. Dotknij dymku błędu, aby zobaczyć szczegóły. + title: Wyczyść dane + configurations: + update: + success: Import został pomyślnie skonfigurowany. + category_import: + button_label: Dalej + description: Prześlij prosty plik CSV, taki jak ten generowany podczas eksportu danych. Kolumny zostaną automatycznie zmapowane. + instructions: Wybierz Dalej, aby przeanalizować plik CSV i przejść do etapu czyszczenia. + mint_import: + date_format_label: Format daty + rule_import: + description: Skonfiguruj import reguł. Reguły zostaną utworzone lub zaktualizowane na podstawie danych z CSV. + process_button: Przetwórz reguły + process_help: Kliknij poniższy przycisk, aby przetworzyć CSV i wygenerować wiersze reguł. + show: + description: Wybierz kolumny odpowiadające poszczególnym polom w pliku CSV. + title: Skonfiguruj import + trade_import: + date_format_label: Format daty + transaction_import: + date_format_label: Format daty + rows_to_skip_label: Pomiń pierwsze n wierszy + confirms: + sure_import: + title: Potwierdź import + description: Przejrzyj dane, które zostaną zaimportowane z pliku eksportu. + summary: Podsumowanie importu + empty_summary: Nie udało się znaleźć w tym pliku żadnych rekordów nadających się do importu. Plik może być pusty albo jego wiersze nie pasują do oczekiwanego formatu eksportu. Każdy wiersz powinien być obiektem JSON z kluczami "type" i "data" oraz typami obsługiwanymi przez ten import. + publish_button: Rozpocznij import + cancel: Anuluj + mappings: + create_account: Utwórz konto + csv_mapping_label: "%{mapping} w CSV" + sure_mapping_label: "%{mapping} w %{product_name}" + no_accounts: Nie masz jeszcze żadnych kont. Utwórz konto, którego można użyć dla nieprzypisanych wierszy w CSV, albo wróć do etapu czyszczenia i podaj nazwę konta, której można użyć. + rows_label: Wiersze + unassigned_account: Chcesz utworzyć nowe konto dla nieprzypisanych wierszy? + show: + account_mapping_description: Przypisz wszystkie konta z importowanego pliku do istniejących kont %{product}. Możesz też dodać nowe konta lub zostawić je bez przypisania. + account_mapping_title: Przypisz konta + account_type_mapping_description: Przypisz wszystkie typy kont z importowanego pliku do typów używanych w %{product}. + account_type_mapping_title: Przypisz typy kont + category_mapping_description: Przypisz wszystkie kategorie z importowanego pliku do istniejących kategorii %{product}. Możesz też dodać nowe kategorie lub zostawić je bez przypisania. + category_mapping_title: Przypisz kategorie + tag_mapping_description: Przypisz wszystkie tagi z importowanego pliku do istniejących tagów %{product}. Możesz też dodać nowe tagi lub zostawić je bez przypisania. + tag_mapping_title: Przypisz tagi + uploads: + show: + qif_title: Prześlij plik QIF + qif_description: Wybierz konto, do którego należy ten plik QIF, a następnie prześlij eksport .qif z Quicken. + qif_account_label: Konto + qif_account_placeholder: Wybierz konto… + qif_file_prompt: aby dodać tutaj plik QIF + qif_file_hint: tylko pliki .qif + qif_submit: Prześlij QIF + browse: Przeglądaj + csv_file_prompt: aby dodać tutaj plik CSV + description: Wklej lub prześlij poniżej swój plik CSV. Przed rozpoczęciem zapoznaj się z instrukcjami w tabeli poniżej. + instructions_1: Poniżej znajduje się przykładowy plik CSV z kolumnami dostępnymi do importu. + instructions_2: Twój plik CSV musi zawierać wiersz nagłówka. + instructions_3: Możesz nazwać kolumny dowolnie. Zmapujesz je na późniejszym etapie. + instructions_4: Kolumny oznaczone gwiazdką (*) są wymagane. + instructions_5: W liczbach nie używaj przecinków, symboli walut ani nawiasów. + title: Zaimportuj dane + sure_import: + title: Importuj z eksportu + description: Prześlij plik all.ndjson z eksportu danych, aby przywrócić konta, transakcje, kategorie i inne dane. + drop_title: Upuść plik NDJSON, aby go przesłać + drop_subtitle: Plik zostanie przesłany automatycznie + browse: Przeglądaj + browse_hint: aby dodać tutaj plik all.ndjson + upload_button: Prześlij NDJSON + hint_html: Prześlij plik all.ndjson z archiwum ZIP eksportu danych + ndjson_invalid: Plik musi być poprawnym NDJSON i zawierać co najmniej jeden rekord + imports: + type_labels: + transaction_import: Import transakcji + trade_import: Import transakcji giełdowych + account_import: Import kont + mint_import: Import z Mint + qif_import: Import QIF + category_import: Import kategorii + rule_import: Import reguł + pdf_import: Import PDF + document_import: Import dokumentu + sure_import: Import z Sure + steps: + upload: Przesyłanie + configure: Konfiguracja + clean: Czyszczenie + map: Mapowanie + confirm: Potwierdzenie + select: Wybór + index: + title: Importy + new: Nowy import + table: + title: Importy + header: + date: Data + operation: Operacja + status: Status + actions: Akcje + row: + type_labels: + transaction_import: Transakcja + trade_import: Transakcja giełdowa + account_import: Konto + mint_import: "Mint" + qif_import: "QIF" + category_import: Kategoria + rule_import: Reguła + pdf_import: "PDF" + document_import: Dokument + sure_import: "Sure" + status: + in_progress: W toku + uploading: Przetwarzanie wierszy + reverting: Cofanie + revert_failed: Nie udało się cofnąć + complete: Zakończono + failed: Niepowodzenie + actions: + revert: Cofnij + confirm_revert: To usunie zaimportowane transakcje, ale nadal będzie można w każdej chwili przejrzeć i ponownie zaimportować dane. + delete: Usuń + view: Zobacz + empty: Brak importów. + new: + description: Importuj dane z narzędzia finansowego lub prześlij surowe pliki danych. + tab_financial_tools: Narzędzia i pliki finansowe + tab_raw_data: Surowe dane + coming_soon: Wkrótce + import_ynab: Importuj z YNAB + import_accounts: Importuj konta + import_categories: Importuj kategorie + import_mint: Importuj z Mint + import_portfolio: Importuj inwestycje + import_rules: Importuj reguły + import_transactions: Importuj transakcje + import_qif: Importuj z Quicken (QIF) + import_sure: Importuj z Sure + import_sure_description: Pełny plik eksportu .ndjson + import_file: Importuj dokument + import_file_description: Analiza PDF wspierana przez AI oraz przesyłanie przeszukiwalnych plików + requires_account: Najpierw zaimportuj konta, aby odblokować tę opcję. + resume: Wznów %{type} + sources: Źródła + title: Nowy import + create: + file_too_large: Plik jest zbyt duży. Maksymalny rozmiar to %{max_size} MB. + invalid_file_type: Nieprawidłowy typ pliku. Prześlij plik CSV. + csv_uploaded: Plik CSV został pomyślnie przesłany. + ndjson_uploaded: Plik NDJSON został pomyślnie przesłany. + pdf_too_large: Plik PDF jest zbyt duży. Maksymalny rozmiar to %{max_size} MB. + pdf_processing: Twój plik PDF jest przetwarzany. Po zakończeniu analizy otrzymasz wiadomość e-mail. + invalid_pdf: Przesłany plik nie jest prawidłowym plikiem PDF. + document_too_large: Plik dokumentu jest zbyt duży. Maksymalny rozmiar to %{max_size} MB. + invalid_document_file_type: Nieprawidłowy typ pliku dokumentu dla aktywnego magazynu wektorowego. + document_uploaded: Dokument został pomyślnie przesłany. + document_upload_failed: Nie udało się przesłać dokumentu do magazynu wektorowego. Spróbuj ponownie. + invalid_ndjson_file_type: Nieprawidłowy typ lub format pliku. Prześlij poprawny plik eksportu .ndjson lub .json. + document_provider_not_configured: Nie skonfigurowano magazynu wektorowego dla przesyłania dokumentów. + show: + finalize_upload: Dokończ przesyłanie pliku. + finalize_mappings: Dokończ mapowanie przed przejściem dalej. + ready: + description: Oto podsumowanie nowych elementów, które zostaną dodane do konta po opublikowaniu tego importu. + title: Potwierdź dane importu + summary_item_label: Element + summary_count_label: Liczba + empty_summary: Nie udało się znaleźć w tym pliku żadnych rekordów nadających się do importu. Plik może być pusty albo jego wiersze nie pasują do oczekiwanego formatu eksportu. Każdy wiersz powinien być obiektem JSON z kluczami "type" i "data" oraz typami obsługiwanymi przez ten import. + publish_import: Opublikuj import + back_to_imports: Wróć do importów + errors: + custom_column_requires_inflow: Import niestandardowych kolumn wymaga wybrania kolumny wpływu + document_types: + bank_statement: Wyciąg bankowy + credit_card_statement: Wyciąg z karty kredytowej + investment_statement: Wyciąg inwestycyjny + financial_document: Dokument finansowy + contract: Umowa + other: Inny dokument + unknown: Nieznany dokument + pdf_import: + processing_title: Przetwarzanie PDF + processing_description: Analizujemy Twój dokument przy użyciu AI. Może to chwilę potrwać. Po zakończeniu analizy otrzymasz wiadomość e-mail. + check_status: Sprawdź status + back_to_dashboard: Wróć do pulpitu + failed_title: Przetwarzanie nie powiodło się + failed_description: Nie udało się przetworzyć dokumentu PDF. Spróbuj ponownie lub skontaktuj się ze wsparciem. + try_again: Spróbuj ponownie + delete_import: Usuń import + complete_title: Dokument przeanalizowany + complete_description: Przeanalizowaliśmy Twój plik PDF. Oto, co znaleźliśmy. + document_type_label: Typ dokumentu + summary_label: Podsumowanie + email_sent_notice: Wysłano do Ciebie wiadomość e-mail z dalszymi krokami. + back_to_imports: Wróć do importów + unknown_state_title: Nieznany stan + unknown_state_description: Ten import jest w nieoczekiwanym stanie. Wróć do listy importów. + processing_failed_with_message: "%{message}" + processing_failed_generic: "Przetwarzanie nie powiodło się: %{error}" diff --git a/config/locales/views/imports/pt-BR.yml b/config/locales/views/imports/pt-BR.yml index 6183ac6bd..84edb7b83 100644 --- a/config/locales/views/imports/pt-BR.yml +++ b/config/locales/views/imports/pt-BR.yml @@ -101,3 +101,8 @@ pt-BR: description: Aqui está um resumo dos novos itens que serão adicionados à sua conta assim que você publicar esta importação. title: Confirmar seus dados de importação + summary_item_label: Item + summary_count_label: Quantidade + empty_summary: Não foi possível encontrar registros importáveis neste arquivo. Ele pode estar vazio, ou as linhas não correspondem ao formato de exportação esperado (cada linha deve ser um objeto JSON com as chaves «type» e «data», usando tipos suportados por esta importação). + publish_import: Publicar importação + back_to_imports: Voltar às importações diff --git a/config/locales/views/imports/ro.yml b/config/locales/views/imports/ro.yml index 76a74371f..bec1cf695 100644 --- a/config/locales/views/imports/ro.yml +++ b/config/locales/views/imports/ro.yml @@ -80,3 +80,8 @@ ro: ready: description: Iată un rezumat al elementelor noi care vor fi adăugate contului tău odată ce vei publica acest import. title: Confirmă datele importate + summary_item_label: Element + summary_count_label: Număr + empty_summary: Nu s-au găsit înregistrări importabile în acest fișier. Fișierul poate fi gol sau liniile nu respectă formatul de export așteptat (fiecare linie trebuie să fie un obiect JSON cu cheile „type” și „data”, folosind tipuri acceptate de acest import). + publish_import: Publică importul + back_to_imports: Înapoi la importuri diff --git a/config/locales/views/imports/tr.yml b/config/locales/views/imports/tr.yml index 1edec3ae2..8296d655c 100644 --- a/config/locales/views/imports/tr.yml +++ b/config/locales/views/imports/tr.yml @@ -79,4 +79,9 @@ tr: title: Yeni CSV İçe Aktarma ready: description: Bu içe aktarmayı yayınladığınızda hesabınıza eklenecek yeni öğelerin özeti aşağıdadır. - title: İçe aktarma verilerinizi onaylayın \ No newline at end of file + title: İçe aktarma verilerinizi onaylayın + summary_item_label: Öğe + summary_count_label: Adet + empty_summary: Bu dosyada içe aktarılabilir kayıt bulunamadı. Dosya boş olabilir veya satırlar beklenen dışa aktarma biçimiyle eşleşmiyor (her satır, bu içe aktarmanın desteklediği türlerle «type» ve «data» anahtarlarına sahip bir JSON nesnesi olmalıdır). + publish_import: İçe aktarmayı yayınla + back_to_imports: İçe aktarmalara dön \ No newline at end of file diff --git a/config/locales/views/imports/zh-CN.yml b/config/locales/views/imports/zh-CN.yml index 9ce64c768..8ea66765b 100644 --- a/config/locales/views/imports/zh-CN.yml +++ b/config/locales/views/imports/zh-CN.yml @@ -90,3 +90,8 @@ zh-CN: ready: description: 以下是发布导入后将添加到您账户的新项目摘要。 title: 确认导入数据 + summary_item_label: 项目 + summary_count_label: 数量 + empty_summary: 在此文件中未找到可导入的记录。文件可能为空,或各行不符合预期的导出格式(每行应为包含「type」和「data」键的 JSON 对象,且类型须为本导入支持的类型)。 + publish_import: 发布导入 + back_to_imports: 返回导入列表 diff --git a/config/locales/views/imports/zh-TW.yml b/config/locales/views/imports/zh-TW.yml index 25c7bd541..a7a47849b 100644 --- a/config/locales/views/imports/zh-TW.yml +++ b/config/locales/views/imports/zh-TW.yml @@ -90,3 +90,8 @@ zh-TW: ready: description: 以下是發佈此匯入後,將新增至您帳戶的項目摘要。 title: 確認您的匯入資料 + summary_item_label: 項目 + summary_count_label: 數量 + empty_summary: 在此檔案中找不到可匯入的記錄。檔案可能是空的,或各行不符合預期的匯出格式(每行應為包含「type」與「data」鍵的 JSON 物件,且類型須為此匯入支援的類型)。 + publish_import: 發佈匯入 + back_to_imports: 返回匯入列表 diff --git a/config/locales/views/indexa_capital_items/de.yml b/config/locales/views/indexa_capital_items/de.yml new file mode 100644 index 000000000..09d0e2a22 --- /dev/null +++ b/config/locales/views/indexa_capital_items/de.yml @@ -0,0 +1,247 @@ +--- +de: + indexa_capital_items: + sync_status: + no_accounts: "Keine Konten gefunden" + synced: + one: "%{count} Konto synchronisiert" + other: "%{count} Konten synchronisiert" + synced_with_setup: "%{linked} synchronisiert, %{unlinked} benötigen Einrichtung" + institution_summary: + none: "Keine Institute verbunden" + count: + one: "%{count} Institut" + other: "%{count} Institute" + errors: + provider_not_configured: "IndexaCapital-Anbieter ist nicht konfiguriert" + + + sync: + status: + importing: "Konten werden von IndexaCapital importiert..." + processing: "Depots und Aktivitäten werden verarbeitet..." + calculating: "Salden werden berechnet..." + importing_data: "Kontodaten werden importiert..." + checking_setup: "Kontokonfiguration wird geprüft..." + needs_setup: "%{count} Konten benötigen Einrichtung..." + success: "Synchronisation gestartet" + + + panel: + setup_instructions: "Einrichtungsanleitung:" + step_1: "Besuchen Sie Ihr IndexaCapital Dashboard, um einen schreibgeschützten API-Token zu erstellen" + step_2: "Fügen Sie Ihren API-Token unten ein und klicken Sie auf Speichern" + step_3: "Nach erfolgreicher Verbindung gehen Sie zur Registerkarte Konten, um neue Konten einzurichten" + field_descriptions: "Feldbeschreibungen:" + optional: "(Optional)" + required: "(Pflichtfeld)" + optional_with_default: "(optional, Standard: %{default_value})" + alternative_auth: "Oder nutzen Sie Benutzername/Passwort-Anmeldung..." + save_button: "Konfiguration speichern" + update_button: "Konfiguration aktualisieren" + status_configured_html: "Konfiguriert und einsatzbereit. Besuchen Sie die Registerkarte Konten, um Konten zu verwalten und einzurichten." + status_not_configured: "Nicht konfiguriert" + fields: + api_token: + label: "API-Token" + description: "Ihr schreibgeschützter API-Token aus dem IndexaCapital Dashboard" + placeholder_new: "API-Token hier einfügen" + placeholder_update: "Neuen API-Token zum Aktualisieren eingeben" + username: + label: "Benutzername" + description: "Ihr IndexaCapital Benutzername/E-Mail" + placeholder_new: "Benutzername hier einfügen" + placeholder_update: "Neuen Benutzernamen zum Aktualisieren eingeben" + document: + label: "Dokument-ID" + description: "Ihre IndexaCapital Dokument-/ID-Nummer" + placeholder_new: "Dokument-ID hier einfügen" + placeholder_update: "Neue Dokument-ID zum Aktualisieren eingeben" + password: + label: "Passwort" + description: "Ihr IndexaCapital Passwort" + placeholder_new: "Passwort hier einfügen" + placeholder_update: "Neues Passwort zum Aktualisieren eingeben" + + + create: + success: "IndexaCapital-Verbindung erfolgreich erstellt" + update: + success: "IndexaCapital-Verbindung aktualisiert" + destroy: + success: "IndexaCapital-Verbindung entfernt" + index: + title: "IndexaCapital-Verbindungen" + + + loading: + loading_message: "IndexaCapital-Konten werden geladen..." + loading_title: "Laden" + + link_accounts: + all_already_linked: + one: "Das ausgewählte Konto (%{names}) ist bereits verknüpft" + other: "Alle %{count} ausgewählten Konten sind bereits verknüpft: %{names}" + api_error: "API-Fehler: %{message}" + invalid_account_names: + one: "Konto mit leerem Namen kann nicht verknüpft werden" + other: "%{count} Konten mit leeren Namen können nicht verknüpft werden" + link_failed: "Konten konnten nicht verknüpft werden" + no_accounts_selected: "Bitte wählen Sie mindestens ein Konto aus" + no_api_key: "IndexaCapital-Zugangsdaten nicht gefunden. Bitte in den Anbieter-Einstellungen konfigurieren." + partial_invalid: "Erfolgreich %{created_count} Konto/Konten verknüpft, %{already_linked_count} waren bereits verknüpft, %{invalid_count} Konto/Konten hatten ungültige Namen" + partial_success: "Erfolgreich %{created_count} Konto/Konten verknüpft. %{already_linked_count} Konto/Konten waren bereits verknüpft: %{already_linked_names}" + success: + one: "Erfolgreich %{count} Konto verknüpft" + other: "Erfolgreich %{count} Konten verknüpft" + + + indexa_capital_item: + accounts_need_setup: "Konten benötigen Einrichtung" + delete: "Verbindung löschen" + deletion_in_progress: "Löschung läuft..." + error: "Fehler" + more_accounts_available: + one: "%{count} weiteres Konto verfügbar" + other: "%{count} weitere Konten verfügbar" + no_accounts_description: "Diese Verbindung hat noch keine verknüpften Konten." + no_accounts_title: "Keine Konten" + provider_name: "IndexaCapital" + requires_update: "Verbindung muss aktualisiert werden" + setup_action: "Neue Konten einrichten" + setup_description: "%{linked} von %{total} Konten verknüpft. Wählen Sie Kontotypen für Ihre neu importierten IndexaCapital-Konten." + setup_needed: "Neue Konten bereit zur Einrichtung" + status: "Vor %{timestamp} synchronisiert — %{summary}" + status_never: "Noch nie synchronisiert" + syncing: "Synchronisiere..." + total: "Gesamt" + unlinked: "Nicht verknüpft" + update_credentials: "Zugangsdaten aktualisieren" + + + select_accounts: + accounts_selected: "Konten ausgewählt" + api_error: "API-Fehler: %{message}" + cancel: "Abbrechen" + configure_name_in_provider: "Import nicht möglich – bitte Kontoname in IndexaCapital konfigurieren" + description: "Wählen Sie die Konten aus, die Sie mit Ihrem %{product_name}-Konto verknüpfen möchten." + link_accounts: "Ausgewählte Konten verknüpfen" + no_accounts_found: "Keine Konten gefunden. Bitte überprüfen Sie Ihre IndexaCapital-Zugangsdaten." + no_api_key: "IndexaCapital-Zugangsdaten sind nicht konfiguriert. Bitte in den Einstellungen konfigurieren." + no_credentials_configured: "Bitte konfigurieren Sie zuerst Ihre IndexaCapital-Zugangsdaten in den Anbieter-Einstellungen." + no_name_placeholder: "(Kein Name)" + title: "IndexaCapital-Konten auswählen" + + select_existing_account: + account_already_linked: "Dieses Konto ist bereits mit einem Anbieter verknüpft" + all_accounts_already_linked: "Alle IndexaCapital-Konten sind bereits verknüpft" + api_error: "API-Fehler: %{message}" + balance_label: "Saldo:" + cancel: "Abbrechen" + cancel_button: "Abbrechen" + configure_name_in_provider: "Import nicht möglich – bitte Kontoname in IndexaCapital konfigurieren" + connect_hint: "Verbinden Sie ein IndexaCapital-Konto für automatische Synchronisation." + description: "Wählen Sie ein IndexaCapital-Konto zur Verknüpfung mit diesem Konto. Transaktionen werden automatisch synchronisiert und dedupliziert." + header: "Mit IndexaCapital verknüpfen" + link_account: "Konto verknüpfen" + link_button: "Dieses Konto verknüpfen" + linking_to: "Verknüpfe mit:" + no_account_specified: "Kein Konto angegeben" + no_accounts: "Keine unverknüpften IndexaCapital-Konten gefunden." + no_accounts_found: "Keine IndexaCapital-Konten gefunden. Bitte überprüfen Sie Ihre Zugangsdaten." + no_api_key: "IndexaCapital-Zugangsdaten sind nicht konfiguriert. Bitte in den Einstellungen konfigurieren." + no_credentials_configured: "Bitte konfigurieren Sie zuerst Ihre IndexaCapital-Zugangsdaten in den Anbieter-Einstellungen." + no_name_placeholder: "(Kein Name)" + settings_link: "Zu den Anbieter-Einstellungen" + subtitle: "IndexaCapital-Konto auswählen" + title: "%{account_name} mit IndexaCapital verknüpfen" + + link_existing_account: + account_already_linked: "Dieses Konto ist bereits mit einem Anbieter verknüpft" + api_error: "API-Fehler: %{message}" + invalid_account_name: "Konto mit leerem Namen kann nicht verknüpft werden" + provider_account_already_linked: "Dieses IndexaCapital-Konto ist bereits mit einem anderen Konto verknüpft" + provider_account_not_found: "IndexaCapital-Konto nicht gefunden" + missing_parameters: "Erforderliche Parameter fehlen" + no_api_key: "IndexaCapital-Zugangsdaten nicht gefunden. Bitte in den Anbieter-Einstellungen konfigurieren." + success: "%{account_name} erfolgreich mit IndexaCapital verknüpft" + + setup_accounts: + account_type_label: "Kontotyp:" + accounts_count: + one: "%{count} Konto verfügbar" + other: "%{count} Konten verfügbar" + all_accounts_linked: "Alle Ihre IndexaCapital-Konten sind bereits eingerichtet." + api_error: "API-Fehler: %{message}" + creating: "Konten werden erstellt..." + fetch_failed: "Konten konnten nicht geladen werden" + import_selected: "Ausgewählte Konten importieren" + instructions: "Wählen Sie die Konten aus, die Sie von IndexaCapital importieren möchten. Sie können mehrere Konten auswählen." + no_accounts: "Keine unverknüpften Konten von dieser IndexaCapital-Verbindung gefunden." + no_accounts_to_setup: "Keine Konten zum Einrichten" + no_api_key: "IndexaCapital-Zugangsdaten sind nicht konfiguriert. Bitte überprüfen Sie Ihre Verbindungseinstellungen." + select_all: "Alle auswählen" + account_types: + skip: "Dieses Konto überspringen" + depository: "Giro- oder Sparkonto" + credit_card: "Kreditkarte" + investment: "Depot/Anlagekonto" + crypto: "Kryptowährungs-Konto" + loan: "Darlehen oder Hypothek" + other_asset: "Sonstiges Vermögen" + subtype_labels: + depository: "Konto-Untertyp:" + credit_card: "" + investment: "Anlagetyp:" + crypto: "" + loan: "Darlehenstyp:" + other_asset: "" + subtype_messages: + credit_card: "Kreditkarten werden automatisch als Kreditkartenkonten eingerichtet." + other_asset: "Für sonstiges Vermögen sind keine weiteren Optionen nötig." + crypto: "Kryptowährungs-Konten werden zur Verwaltung von Beständen und Transaktionen eingerichtet." + subtypes: + depository: + checking: "Girokonto" + savings: "Sparkonto" + hsa: "Gesundheits-Sparkonto" + cd: "Festgeld" + money_market: "Geldmarkt" + investment: + brokerage: "Brokerage" + pension: "Rente" + retirement: "Altersvorsorge" + "401k": "401(k)" + roth_401k: "Roth 401(k)" + "403b": "403(b)" + tsp: "Thrift Savings Plan" + "529_plan": "529 Plan" + hsa: "Gesundheits-Sparkonto" + mutual_fund: "Investmentfonds" + ira: "Traditioneller IRA" + roth_ira: "Roth IRA" + angel: "Angel" + loan: + mortgage: "Hypothek" + student: "Studienkredit" + auto: "Autokredit" + other: "Sonstiges Darlehen" + balance: "Saldo" + cancel: "Abbrechen" + choose_account_type: "Wählen Sie den passenden Kontotyp für jedes IndexaCapital-Konto:" + create_accounts: "Konten erstellen" + creating_accounts: "Konten werden erstellt..." + historical_data_range: "Zeitraum für Verlauf:" + subtitle: "Wählen Sie die passenden Kontotypen für Ihre importierten Konten" + sync_start_date_help: "Wählen Sie, wie weit die Transaktionshistorie synchronisiert werden soll." + sync_start_date_label: "Transaktionen synchronisieren ab:" + title: "Ihre IndexaCapital-Konten einrichten" + + complete_account_setup: + all_skipped: "Alle Konten wurden übersprungen. Es wurden keine Konten erstellt." + creation_failed: "Konten konnten nicht erstellt werden: %{error}" + no_accounts: "Keine Konten zum Einrichten." + success: "Erfolgreich %{count} Konto/Konten erstellt." + + preload_accounts: + no_credentials_configured: "Bitte konfigurieren Sie zuerst Ihre IndexaCapital-Zugangsdaten in den Anbieter-Einstellungen." diff --git a/config/locales/views/indexa_capital_items/es.yml b/config/locales/views/indexa_capital_items/es.yml new file mode 100644 index 000000000..f63db32d8 --- /dev/null +++ b/config/locales/views/indexa_capital_items/es.yml @@ -0,0 +1,241 @@ +--- +es: + indexa_capital_items: + sync_status: + no_accounts: "No se han encontrado cuentas" + synced: + one: "%{count} cuenta sincronizada" + other: "%{count} cuentas sincronizadas" + synced_with_setup: "%{linked} sincronizadas, %{unlinked} necesitan configuración" + institution_summary: + none: "No hay instituciones conectadas" + count: + one: "%{count} institución" + other: "%{count} instituciones" + errors: + provider_not_configured: "El proveedor Indexa Capital no está configurado" + + sync: + status: + importing: "Importando cuentas de Indexa Capital..." + processing: "Procesando posiciones y actividades..." + calculating: "Calculando saldos..." + importing_data: "Importando datos de la cuenta..." + checking_setup: "Comprobando la configuración de la cuenta..." + needs_setup: "%{count} cuentas necesitan configuración..." + success: "Sincronización iniciada" + + panel: + setup_instructions: "Instrucciones de configuración:" + step_1: "Visita tu panel de Indexa Capital para generar un token de API de solo lectura" + step_2: "Pega tu token de API a continuación y haz clic en Guardar" + step_3: "Tras una conexión exitosa, ve a la pestaña Cuentas para configurar las nuevas cuentas" + field_descriptions: "Descripciones de los campos:" + optional: "(Opcional)" + required: "(obligatorio)" + optional_with_default: "(opcional, por defecto %{default_value})" + alternative_auth: "O usa la autenticación por usuario/contraseña en su lugar..." + save_button: "Guardar configuración" + update_button: "Actualizar configuración" + status_configured_html: "Configurado y listo para usar. Visita la pestaña de Cuentas para gestionar y configurar tus cuentas." + status_not_configured: "No configurado" + fields: + api_token: + label: "Token de API" + description: "Tu token de API de solo lectura del panel de Indexa Capital" + placeholder_new: "Pega tu token de API aquí" + placeholder_update: "Introduce el nuevo token de API para actualizar" + username: + label: "Usuario" + description: "Tu usuario/email de Indexa Capital" + placeholder_new: "Pega el usuario aquí" + placeholder_update: "Introduce el nuevo usuario para actualizar" + document: + label: "Documento de identidad" + description: "Tu documento/ID de Indexa Capital" + placeholder_new: "Pega el ID del documento aquí" + placeholder_update: "Introduce el nuevo ID de documento para actualizar" + password: + label: "Contraseña" + description: "Tu contraseña de Indexa Capital" + placeholder_new: "Pega la contraseña aquí" + placeholder_update: "Introduce la nueva contraseña para actualizar" + + create: + success: "Conexión con Indexa Capital creada correctamente" + update: + success: "Conexión con Indexa Capital actualizada" + destroy: + success: "Conexión con Indexa Capital eliminada" + index: + title: "Conexiones de Indexa Capital" + + loading: + loading_message: "Cargando cuentas de Indexa Capital..." + loading_title: "Cargando" + + link_accounts: + all_already_linked: + one: "La cuenta seleccionada (%{names}) ya está vinculada" + other: "Las %{count} cuentas seleccionadas ya están vinculadas: %{names}" + api_error: "Error de la API: %{message}" + invalid_account_names: + one: "No se puede vincular una cuenta sin nombre" + other: "No se pueden vincular %{count} cuentas sin nombre" + link_failed: "Error al vincular las cuentas" + no_accounts_selected: "Por favor, selecciona al menos una cuenta" + no_api_key: "No se han encontrado las credenciales de Indexa Capital. Por favor, configúralas en los ajustes del proveedor." + partial_invalid: "Se han vinculado correctamente %{created_count} cuenta(s), %{already_linked_count} ya estaban vinculadas, %{invalid_count} cuenta(s) tenían nombres no válidos" + partial_success: "Se han vinculado correctamente %{created_count} cuenta(s). %{already_linked_count} cuenta(s) ya estaban vinculadas: %{already_linked_names}" + success: + one: "Cuenta vinculada correctamente" + other: "%{count} cuentas vinculadas correctamente" + + indexa_capital_item: + accounts_need_setup: "Las cuentas necesitan configuración" + delete: "Eliminar conexión" + deletion_in_progress: "eliminación en curso..." + error: "Error" + more_accounts_available: + one: "Hay %{count} cuenta más disponible" + other: "Hay %{count} cuentas más disponibles" + no_accounts_description: "Esta conexión aún no tiene cuentas vinculadas." + no_accounts_title: "Sin cuentas" + provider_name: "Indexa Capital" + requires_update: "La conexión necesita una actualización" + setup_action: "Configurar nuevas cuentas" + setup_description: "%{linked} de %{total} cuentas vinculadas. Elige los tipos de cuenta para tus cuentas de Indexa Capital recién importadas." + setup_needed: "Nuevas cuentas listas para configurar" + status: "Sincronizado hace %{timestamp} — %{summary}" + status_never: "Nunca sincronizado" + syncing: "Sincronizando..." + total: "Total" + unlinked: "Desvinculadas" + update_credentials: "Actualizar credenciales" + + select_accounts: + accounts_selected: "cuentas seleccionadas" + api_error: "Error de la API: %{message}" + cancel: "Cancelar" + configure_name_in_provider: "No se puede importar; por favor, configura el nombre de la cuenta en Indexa Capital" + description: "Selecciona las cuentas que quieres vincular a tu cuenta de %{product_name}." + link_accounts: "Vincular cuentas seleccionadas" + no_accounts_found: "No se han encontrado cuentas. Por favor, comprueba tus credenciales de Indexa Capital." + no_api_key: "Las credenciales de Indexa Capital no están configuradas. Por favor, configúralas en Ajustes." + no_credentials_configured: "Por favor, configura primero tus credenciales de Indexa Capital en los ajustes del proveedor." + no_name_placeholder: "(Sin nombre)" + title: "Seleccionar cuentas de Indexa Capital" + + select_existing_account: + account_already_linked: "Esta cuenta ya está vinculada a un proveedor" + all_accounts_already_linked: "Todas las cuentas de Indexa Capital ya están vinculadas" + api_error: "Error de la API: %{message}" + balance_label: "Saldo:" + cancel: "Cancelar" + cancel_button: "Cancelar" + configure_name_in_provider: "No se puede importar; por favor, configura el nombre de la cuenta en Indexa Capital" + connect_hint: "Conecta una cuenta de Indexa Capital para habilitar la sincronización automática." + description: "Selecciona una cuenta de Indexa Capital para vincularla con esta cuenta. Las transacciones se sincronizarán y se eliminarán los duplicados automáticamente." + header: "Vincular con Indexa Capital" + link_account: "Vincular cuenta" + link_button: "Vincular esta cuenta" + linking_to: "Vinculando a:" + no_account_specified: "No se ha especificado ninguna cuenta" + no_accounts: "No se han encontrado cuentas de Indexa Capital sin vincular." + no_accounts_found: "No se han encontrado cuentas de Indexa Capital. Por favor, comprueba tus credenciales." + no_api_key: "Las credenciales de Indexa Capital no están configuradas. Por favor, configúralas en Ajustes." + no_credentials_configured: "Por favor, configura primero tus credenciales de Indexa Capital en los ajustes del proveedor." + no_name_placeholder: "(Sin nombre)" + settings_link: "Ir a ajustes del proveedor" + subtitle: "Elige una cuenta de Indexa Capital" + title: "Vincular %{account_name} con Indexa Capital" + + link_existing_account: + account_already_linked: "Esta cuenta ya está vinculada a un proveedor" + api_error: "Error de la API: %{message}" + invalid_account_name: "No se puede vincular una cuenta sin nombre" + provider_account_already_linked: "Esta cuenta de Indexa Capital ya está vinculada a otra cuenta" + provider_account_not_found: "Cuenta de Indexa Capital no encontrada" + missing_parameters: "Faltan parámetros obligatorios" + no_api_key: "No se han encontrado las credenciales de Indexa Capital. Por favor, configúralas en los ajustes del proveedor." + success: "Se ha vinculado correctamente %{account_name} con Indexa Capital" + + setup_accounts: + account_type_label: "Tipo de cuenta:" + accounts_count: + one: "%{count} cuenta disponible" + other: "%{count} cuentas disponibles" + all_accounts_linked: "Todas tus cuentas de Indexa Capital ya han sido configuradas." + api_error: "Error de la API: %{message}" + creating: "Creando cuentas..." + fetch_failed: "Error al obtener las cuentas" + import_selected: "Importar cuentas seleccionadas" + instructions: "Selecciona las cuentas que quieres importar de Indexa Capital. Puedes elegir varias cuentas." + no_accounts: "No se han encontrado cuentas sin vincular en esta conexión de Indexa Capital." + no_accounts_to_setup: "No hay cuentas para configurar" + no_api_key: "Las credenciales de Indexa Capital no están configuradas. Por favor, comprueba los ajustes de conexión." + select_all: "Seleccionar todas" + account_types: + skip: "Omitir esta cuenta" + depository: "Cuenta corriente o de ahorro" + credit_card: "Tarjeta de crédito" + investment: "Cuenta de inversión" + crypto: "Cuenta de criptomonedas" + loan: "Préstamo o hipoteca" + other_asset: "Otro activo" + subtype_labels: + depository: "Subtipo de cuenta:" + credit_card: "" + investment: "Tipo de inversión:" + crypto: "" + loan: "Tipo de préstamo:" + other_asset: "" + subtype_messages: + credit_card: "Las tarjetas de crédito se configurarán automáticamente como cuentas de tarjeta de crédito." + other_asset: "No se necesitan opciones adicionales para otros activos." + crypto: "Las cuentas de criptomonedas se configurarán para seguir posiciones y transacciones." + subtypes: + depository: + checking: "Corriente" + savings: "Ahorros" + hsa: "Cuenta de ahorros para la salud (HSA)" + cd: "Certificado de depósito" + money_market: "Mercado monetario" + investment: + brokerage: "Bróker" + pension: "Plan de pensiones" + retirement: "Jubilación" + "401k": "401(k)" + roth_401k: "Roth 401(k)" + "403b": "403(b)" + tsp: "Plan de ahorro TSP" + "529_plan": "Plan 529" + hsa: "Cuenta de ahorros para la salud (HSA)" + mutual_fund: "Fondo de inversión" + ira: "IRA tradicional" + roth_ira: "Roth IRA" + angel: "Capital riesgo / Angel" + loan: + mortgage: "Hipoteca" + student: "Préstamo estudiantil" + auto: "Préstamo de coche" + other: "Otro préstamo" + balance: "Saldo" + cancel: "Cancelar" + choose_account_type: "Elige el tipo de cuenta correcto para cada cuenta de Indexa Capital:" + create_accounts: "Crear cuentas" + creating_accounts: "Creando cuentas..." + historical_data_range: "Rango de datos históricos:" + subtitle: "Elige los tipos de cuenta correctos para tus cuentas importadas" + sync_start_date_help: "Selecciona desde qué fecha quieres sincronizar el historial de transacciones." + sync_start_date_label: "Empezar a sincronizar transacciones desde:" + title: "Configura tus cuentas de Indexa Capital" + + complete_account_setup: + all_skipped: "Se han omitido todas las cuentas. No se ha creado ninguna." + creation_failed: "Error al crear las cuentas: %{error}" + no_accounts: "No hay cuentas para configurar." + success: "Se han creado correctamente %{count} cuenta(s)." + + preload_accounts: + no_credentials_configured: "Por favor, configura primero tus credenciales de Indexa Capital en los ajustes del proveedor." \ No newline at end of file diff --git a/config/locales/views/indexa_capital_items/pl.yml b/config/locales/views/indexa_capital_items/pl.yml new file mode 100644 index 000000000..abab63caa --- /dev/null +++ b/config/locales/views/indexa_capital_items/pl.yml @@ -0,0 +1,247 @@ +--- +pl: + indexa_capital_items: + sync_status: + no_accounts: Nie znaleziono kont + synced: + one: Zsynchronizowano %{count} konto + few: Zsynchronizowano %{count} konta + many: Zsynchronizowano %{count} kont + other: Zsynchronizowano %{count} konta + synced_with_setup: Zsynchronizowano %{linked}, %{unlinked} wymaga konfiguracji + institution_summary: + none: Nie połączono żadnych instytucji + count: + one: "%{count} instytucja" + few: "%{count} instytucje" + many: "%{count} instytucji" + other: "%{count} instytucji" + errors: + provider_not_configured: Dostawca IndexaCapital nie jest skonfigurowany + sync: + status: + importing: Importowanie kont z IndexaCapital... + processing: Przetwarzanie pozycji i aktywności... + calculating: Obliczanie sald... + importing_data: Importowanie danych konta... + checking_setup: Sprawdzanie konfiguracji kont... + needs_setup: "%{count} kont wymaga konfiguracji..." + success: Synchronizacja rozpoczęta + panel: + setup_instructions: 'Instrukcje konfiguracji:' + step_1: Wejdź do panelu Indexa Capital, aby wygenerować token API tylko do odczytu + step_2: Wklej poniżej token API i kliknij Zapisz + step_3: Po pomyślnym połączeniu przejdź do zakładki Konta, aby skonfigurować nowe konta + field_descriptions: 'Opisy pól:' + optional: "(opcjonalne)" + required: "(wymagane)" + optional_with_default: "(opcjonalne, domyślnie %{default_value})" + alternative_auth: Lub użyj uwierzytelniania loginem i hasłem... + save_button: Zapisz konfigurację + update_button: Zaktualizuj konfigurację + status_configured_html: Skonfigurowano i gotowe do użycia. Przejdź do zakładki Konta, aby zarządzać kontami i je konfigurować. + status_not_configured: Nie skonfigurowano + fields: + api_token: + label: Token API + description: Twój token API tylko do odczytu z panelu Indexa Capital + placeholder_new: Wklej tutaj swój token API + placeholder_update: Wprowadź nowy token API, aby zaktualizować + username: + label: Nazwa użytkownika + description: Twoja nazwa użytkownika lub e-mail w Indexa Capital + placeholder_new: Wklej tutaj nazwę użytkownika + placeholder_update: Wprowadź nową nazwę użytkownika, aby zaktualizować + document: + label: ID dokumentu + description: Twój dokument lub identyfikator w Indexa Capital + placeholder_new: Wklej tutaj identyfikator dokumentu + placeholder_update: Wprowadź nowy identyfikator dokumentu, aby zaktualizować + password: + label: Hasło + description: Twoje hasło do Indexa Capital + placeholder_new: Wklej tutaj hasło + placeholder_update: Wprowadź nowe hasło, aby zaktualizować + create: + success: Połączenie IndexaCapital zostało pomyślnie utworzone + update: + success: Połączenie IndexaCapital zostało zaktualizowane + destroy: + success: Połączenie IndexaCapital zostało usunięte + index: + title: Połączenia IndexaCapital + loading: + loading_message: Ładowanie kont IndexaCapital... + loading_title: Ładowanie + link_accounts: + all_already_linked: + one: Wybrane konto (%{names}) jest już połączone + few: 'Wszystkie %{count} wybrane konta są już połączone: %{names}' + many: 'Wszystkie %{count} wybranych kont są już połączone: %{names}' + other: 'Wszystkie %{count} wybrane konta są już połączone: %{names}' + api_error: 'Błąd API: %{message}' + invalid_account_names: + one: Nie można połączyć konta bez nazwy + few: Nie można połączyć %{count} kont bez nazw + many: Nie można połączyć %{count} kont bez nazw + other: Nie można połączyć %{count} kont bez nazw + link_failed: Nie udało się połączyć kont + no_accounts_selected: Wybierz co najmniej jedno konto + no_api_key: Nie znaleziono danych logowania IndexaCapital. Skonfiguruj je w ustawieniach dostawców. + partial_invalid: Pomyślnie połączono %{created_count} kont, %{already_linked_count} było już połączonych, a %{invalid_count} miało nieprawidłowe nazwy + partial_success: 'Pomyślnie połączono %{created_count} kont. %{already_linked_count} było już połączonych: %{already_linked_names}' + success: + one: Pomyślnie połączono %{count} konto + few: Pomyślnie połączono %{count} konta + many: Pomyślnie połączono %{count} kont + other: Pomyślnie połączono %{count} konta + indexa_capital_item: + accounts_need_setup: Konta wymagają konfiguracji + delete: Usuń połączenie + deletion_in_progress: trwa usuwanie... + error: Błąd + more_accounts_available: + one: Dostępne jest jeszcze %{count} konto + few: Dostępne są jeszcze %{count} konta + many: Dostępnych jest jeszcze %{count} kont + other: Dostępnych jest jeszcze %{count} konta + no_accounts_description: To połączenie nie ma jeszcze żadnych połączonych kont. + no_accounts_title: Brak kont + provider_name: IndexaCapital + requires_update: Połączenie wymaga aktualizacji + setup_action: Skonfiguruj nowe konta + setup_description: Połączono %{linked} z %{total} kont. Wybierz typy dla nowo zaimportowanych kont IndexaCapital. + setup_needed: Nowe konta gotowe do konfiguracji + status: Zsynchronizowano %{timestamp} temu — %{summary} + status_never: Nigdy nie synchronizowano + syncing: Trwa synchronizacja... + total: Łącznie + unlinked: Niepołączone + update_credentials: Zaktualizuj dane uwierzytelniające + select_accounts: + accounts_selected: wybrano konta + api_error: 'Błąd API: %{message}' + cancel: Anuluj + configure_name_in_provider: Nie można importować. Skonfiguruj nazwę konta w IndexaCapital. + description: Wybierz konta, które chcesz połączyć ze swoim kontem %{product_name}. + link_accounts: Połącz wybrane konta + no_accounts_found: Nie znaleziono kont. Sprawdź dane logowania IndexaCapital. + no_api_key: Dane logowania IndexaCapital nie są skonfigurowane. Skonfiguruj je w Ustawieniach. + no_credentials_configured: Najpierw skonfiguruj dane logowania IndexaCapital w ustawieniach dostawców. + no_name_placeholder: "(Brak nazwy)" + title: Wybierz konta IndexaCapital + select_existing_account: + account_already_linked: To konto jest już połączone z dostawcą + all_accounts_already_linked: Wszystkie konta IndexaCapital są już połączone + api_error: 'Błąd API: %{message}' + balance_label: 'Saldo:' + cancel: Anuluj + cancel_button: Anuluj + configure_name_in_provider: Nie można importować. Skonfiguruj nazwę konta w IndexaCapital. + connect_hint: Połącz konto IndexaCapital, aby włączyć automatyczną synchronizację. + description: Wybierz konto IndexaCapital, które chcesz połączyć z tym kontem. Transakcje będą synchronizowane i automatycznie deduplikowane. + header: Połącz z IndexaCapital + link_account: Połącz konto + link_button: Połącz to konto + linking_to: 'Łączenie z:' + no_account_specified: Nie wskazano konta + no_accounts: Nie znaleziono niepołączonych kont IndexaCapital. + no_accounts_found: Nie znaleziono kont IndexaCapital. Sprawdź dane logowania. + no_api_key: Dane logowania IndexaCapital nie są skonfigurowane. Skonfiguruj je w Ustawieniach. + no_credentials_configured: Najpierw skonfiguruj dane logowania IndexaCapital w ustawieniach dostawców. + no_name_placeholder: "(Brak nazwy)" + settings_link: Przejdź do ustawień dostawców + subtitle: Wybierz konto IndexaCapital + title: Połącz %{account_name} z IndexaCapital + link_existing_account: + account_already_linked: To konto jest już połączone z dostawcą + api_error: 'Błąd API: %{message}' + invalid_account_name: Nie można połączyć konta bez nazwy + provider_account_already_linked: To konto IndexaCapital jest już połączone z innym kontem + provider_account_not_found: Nie znaleziono konta IndexaCapital + missing_parameters: Brakuje wymaganych parametrów + no_api_key: Nie znaleziono danych logowania IndexaCapital. Skonfiguruj je w ustawieniach dostawców. + success: Pomyślnie połączono %{account_name} z IndexaCapital + setup_accounts: + account_type_label: 'Typ konta:' + accounts_count: + one: Dostępne %{count} konto + few: Dostępne %{count} konta + many: Dostępnych %{count} kont + other: Dostępnych %{count} konta + all_accounts_linked: Wszystkie Twoje konta IndexaCapital zostały już skonfigurowane. + api_error: 'Błąd API: %{message}' + creating: Tworzenie kont... + fetch_failed: Nie udało się pobrać kont + import_selected: Importuj wybrane konta + instructions: Wybierz konta, które chcesz zaimportować z IndexaCapital. Możesz wybrać wiele kont. + no_accounts: Nie znaleziono niepołączonych kont dla tego połączenia IndexaCapital. + no_accounts_to_setup: Brak kont do skonfigurowania + no_api_key: Dane logowania IndexaCapital nie są skonfigurowane. Sprawdź ustawienia połączenia. + select_all: Zaznacz wszystkie + account_types: + skip: Pomiń to konto + depository: Konto bieżące lub oszczędnościowe + credit_card: Karta kredytowa + investment: Konto inwestycyjne + crypto: Konto kryptowalutowe + loan: Pożyczka lub hipoteka + other_asset: Inne aktywo + subtype_labels: + depository: 'Podtyp konta:' + credit_card: '' + investment: 'Typ inwestycji:' + crypto: '' + loan: 'Typ pożyczki:' + other_asset: '' + subtype_messages: + credit_card: Karty kredytowe zostaną automatycznie skonfigurowane jako konta kart kredytowych. + other_asset: Dla innych aktywów nie są potrzebne dodatkowe opcje. + crypto: Konta kryptowalutowe zostaną skonfigurowane do śledzenia pozycji i transakcji. + subtypes: + depository: + checking: Bieżące + savings: Oszczędnościowe + hsa: Konto oszczędnościowe na cele zdrowotne + cd: Lokata terminowa + money_market: Rynek pieniężny + investment: + brokerage: Maklerskie + pension: Emerytalne + retirement: Emerytalne + 401k: 401(k) + roth_401k: Roth 401(k) + 403b: 403(b) + tsp: Plan oszczędnościowy TSP + 529_plan: Plan 529 + hsa: Konto oszczędnościowe na cele zdrowotne + mutual_fund: Fundusz inwestycyjny + ira: Tradycyjne IRA + roth_ira: Roth IRA + angel: Inwestycja anielska + loan: + mortgage: Hipoteka + student: Pożyczka studencka + auto: Kredyt samochodowy + other: Inna pożyczka + balance: Saldo + cancel: Anuluj + choose_account_type: 'Wybierz poprawny typ konta dla każdego konta IndexaCapital:' + create_accounts: Utwórz konta + creating_accounts: Tworzenie kont... + historical_data_range: 'Zakres danych historycznych:' + subtitle: Wybierz poprawne typy dla importowanych kont + sync_start_date_help: Wybierz, jak daleko wstecz chcesz synchronizować historię transakcji. + sync_start_date_label: 'Rozpocznij synchronizację transakcji od:' + title: Skonfiguruj konta IndexaCapital + complete_account_setup: + all_skipped: Wszystkie konta zostały pominięte. Nie utworzono żadnych kont. + creation_failed: 'Nie udało się utworzyć kont: %{error}' + no_accounts: Brak kont do skonfigurowania. + success: + one: Pomyślnie utworzono %{count} konto. + few: Pomyślnie utworzono %{count} konta. + many: Pomyślnie utworzono %{count} kont. + other: Pomyślnie utworzono %{count} konta. + preload_accounts: + no_credentials_configured: Najpierw skonfiguruj dane logowania IndexaCapital w ustawieniach dostawców. diff --git a/config/locales/views/investments/de.yml b/config/locales/views/investments/de.yml index b7ba4e403..ba6066fdb 100644 --- a/config/locales/views/investments/de.yml +++ b/config/locales/views/investments/de.yml @@ -10,6 +10,109 @@ de: title: Kontostand eingeben show: chart_title: Gesamtwert + subtypes: + brokerage: + short: Brokerage + long: Brokerage + "401k": + short: "401(k)" + long: "401(k)" + roth_401k: + short: Roth 401(k) + long: Roth 401(k) + "403b": + short: "403(b)" + long: "403(b)" + "457b": + short: "457(b)" + long: "457(b)" + tsp: + short: TSP + long: Thrift Savings Plan + ira: + short: IRA + long: Traditionelles IRA + roth_ira: + short: Roth IRA + long: Roth IRA + sep_ira: + short: SEP IRA + long: SEP IRA + simple_ira: + short: SIMPLE IRA + long: SIMPLE IRA + "529_plan": + short: "529 Plan" + long: "529 Bildungssparplan" + hsa: + short: HSA + long: Health Savings Account + ugma: + short: UGMA + long: UGMA-Treuhandkonto + utma: + short: UTMA + long: UTMA-Treuhandkonto + isa: + short: ISA + long: Individual Savings Account + lisa: + short: LISA + long: Lifetime ISA + sipp: + short: SIPP + long: Self-Invested Personal Pension + workplace_pension_uk: + short: Pension + long: Betriebliche Altersvorsorge + rrsp: + short: RRSP + long: Registered Retirement Savings Plan + tfsa: + short: TFSA + long: Tax-Free Savings Account + resp: + short: RESP + long: Registered Education Savings Plan + lira: + short: LIRA + long: Locked-In Retirement Account + rrif: + short: RRIF + long: Registered Retirement Income Fund + super: + short: Super + long: Superannuation + smsf: + short: SMSF + long: Self-Managed Super Fund + pea: + short: PEA + long: Plan d'Épargne en Actions + pillar_3a: + short: Säule 3a + long: Private Vorsorge (Säule 3a) + riester: + short: Riester + long: Riester-Rente + pension: + short: Pension + long: Pension + retirement: + short: Ruhestand + long: Ruhestandskonto + mutual_fund: + short: Fonds + long: Investmentfonds + angel: + short: Angel + long: Angel-Investment + trust: + short: Trust + long: Trust + other: + short: Sonstige + long: Sonstige Anlage value_tooltip: cash: Bargeld holdings: Positionen diff --git a/config/locales/views/investments/es.yml b/config/locales/views/investments/es.yml index 14db2128f..c1a3ac0e3 100644 --- a/config/locales/views/investments/es.yml +++ b/config/locales/views/investments/es.yml @@ -10,8 +10,117 @@ es: title: Introduce el saldo de la cuenta show: chart_title: Valor total + subtypes: + # Estados Unidos + brokerage: + short: Corretaje + long: Cuenta de corretaje (Brokerage) + 401k: + short: 401(k) + long: Plan de jubilación 401(k) + roth_401k: + short: Roth 401(k) + long: Plan de jubilación Roth 401(k) + 403b: + short: 403(b) + long: Plan de jubilación 403(b) + 457b: + short: 457(b) + long: Plan de jubilación 457(b) + tsp: + short: TSP + long: Thrift Savings Plan (Plan de ahorro para empleados federales) + ira: + short: IRA + long: Cuenta de jubilación individual (Traditional IRA) + roth_ira: + short: Roth IRA + long: Cuenta de jubilación individual Roth (Roth IRA) + sep_ira: + short: SEP IRA + long: SEP IRA (Plan de pensión simplificado para empleados) + simple_ira: + short: SIMPLE IRA + long: SIMPLE IRA (Plan de incentivos para empleados) + 529_plan: + short: Plan 529 + long: Plan 529 de ahorro para educación + hsa: + short: HSA + long: Cuenta de ahorros para la salud (Health Savings Account) + ugma: + short: UGMA + long: Cuenta de custodia UGMA + utma: + short: UTMA + long: Cuenta de custodia UTMA + # Reino Unido + isa: + short: ISA + long: Cuenta de ahorro individual (ISA) + lisa: + short: LISA + long: ISA vitalicia (Lifetime ISA) + sipp: + short: SIPP + long: Pensión personal con gestión propia (SIPP) + workplace_pension_uk: + short: Pensión + long: Pensión del lugar de trabajo + # Canadá + rrsp: + short: RRSP + long: Plan registrado de ahorro para la jubilación (RRSP) + tfsa: + short: TFSA + long: Cuenta de ahorros libre de impuestos (TFSA) + resp: + short: RESP + long: Plan registrado de ahorros para educación (RESP) + lira: + short: LIRA + long: Cuenta de jubilación inmovilizada (LIRA) + rrif: + short: RRIF + long: Fondo registrado de ingresos para la jubilación (RRIF) + # Australia + super: + short: Super + long: Superannuation (Fondo de pensiones australiano) + smsf: + short: SMSF + long: Fondo de pensiones gestionado por uno mismo (SMSF) + # Europa + pea: + short: PEA + long: Plan de ahorro en acciones (PEA - Francia) + pillar_3a: + short: Pilar 3a + long: Pensión privada (Pilar 3a - Suiza) + riester: + short: Riester + long: Plan de pensiones Riester (Riester-Rente - Alemania) + # Genéricos + pension: + short: Pensión + long: Plan de pensiones + retirement: + short: Jubilación + long: Cuenta de jubilación + mutual_fund: + short: Fondo de inversión + long: Fondo de inversión (Mutual Fund) + angel: + short: Angel + long: Inversión Angel (Capital riesgo) + trust: + short: Fideicomiso + long: Fideicomiso (Trust) + other: + short: Otra + long: Otra inversión value_tooltip: cash: Efectivo holdings: Inversiones total: Saldo de la cartera - total_value_tooltip: El saldo total de la cartera es la suma del efectivo de corretaje (disponible para operar) y el valor de mercado actual de tus inversiones. + total_value_tooltip: El saldo total de la cartera es la suma del efectivo de corretaje (disponible para operar) y el valor de mercado actual de tus inversiones. \ No newline at end of file diff --git a/config/locales/views/investments/pl.yml b/config/locales/views/investments/pl.yml new file mode 100644 index 000000000..94ab55f34 --- /dev/null +++ b/config/locales/views/investments/pl.yml @@ -0,0 +1,120 @@ +--- +pl: + investments: + edit: + edit: Edytuj %{account} + form: + none: Brak + subtype_prompt: Wybierz typ inwestycji + new: + title: Wprowadź saldo konta + show: + chart_title: Łączna wartość + subtypes: + brokerage: + short: Maklerskie + long: Konto maklerskie + 401k: + short: 401(k) + long: 401(k) + roth_401k: + short: Roth 401(k) + long: Roth 401(k) + 403b: + short: 403(b) + long: 403(b) + 457b: + short: 457(b) + long: 457(b) + tsp: + short: TSP + long: Plan oszczędnościowy Thrift + ira: + short: IRA + long: Tradycyjne IRA + roth_ira: + short: Roth IRA + long: Roth IRA + sep_ira: + short: SEP IRA + long: SEP IRA + simple_ira: + short: SIMPLE IRA + long: SIMPLE IRA + 529_plan: + short: 529 Plan + long: Edukacyjny plan oszczędnościowy 529 + hsa: + short: HSA + long: Konto oszczędnościowe na cele zdrowotne + ugma: + short: UGMA + long: Powiernicze konto UGMA + utma: + short: UTMA + long: Powiernicze konto UTMA + isa: + short: ISA + long: Indywidualne konto oszczędnościowe + lisa: + short: LISA + long: Dożywotnie konto ISA + sipp: + short: SIPP + long: Indywidualna emerytura samodzielnie inwestowana + workplace_pension_uk: + short: Emerytalne + long: Emerytura pracownicza + rrsp: + short: RRSP + long: Zarejestrowany emerytalny plan oszczędnościowy + tfsa: + short: TFSA + long: Konto oszczędnościowe zwolnione z podatku + resp: + short: RESP + long: Zarejestrowany edukacyjny plan oszczędnościowy + lira: + short: LIRA + long: Zablokowane konto emerytalne + rrif: + short: RRIF + long: Zarejestrowany fundusz dochodu emerytalnego + super: + short: Super + long: Fundusz emerytalny + smsf: + short: SMSF + long: Samodzielnie zarządzany fundusz emerytalny + pea: + short: PEA + long: Plan oszczędnościowy w akcjach (PEA) + pillar_3a: + short: Pillar 3a + long: Prywatna emerytura (Filar 3a) + riester: + short: Riester + long: Riester-Rente + pension: + short: Emerytalne + long: Emerytalne + retirement: + short: Emerytalne + long: Konto emerytalne + mutual_fund: + short: Fundusz + long: Fundusz inwestycyjny + angel: + short: Anioł + long: Inwestycja anielska + trust: + short: Trust + long: Trust + other: + short: Inne + long: Inna inwestycja + value_tooltip: + cash: Gotówka + holdings: Pozycje + total: Saldo portfela + total_value_tooltip: Łączne saldo portfela to suma gotówki maklerskiej (dostępnej do handlu) oraz aktualnej wartości rynkowej Twoich pozycji. diff --git a/config/locales/views/invitation_mailer/pl.yml b/config/locales/views/invitation_mailer/pl.yml new file mode 100644 index 000000000..dadffa0a8 --- /dev/null +++ b/config/locales/views/invitation_mailer/pl.yml @@ -0,0 +1,8 @@ +--- +pl: + invitation_mailer: + invite_email: + accept_button: Zaakceptuj zaproszenie + body: "%{inviter} zaprosił(a) Cię do dołączenia do %{family} %{moniker} w %{product_name}!" + expiry_notice: To zaproszenie wygaśnie za %{days} dni + greeting: Witamy w %{product_name}! diff --git a/config/locales/views/invitations/de.yml b/config/locales/views/invitations/de.yml index 759899268..8aec53344 100644 --- a/config/locales/views/invitations/de.yml +++ b/config/locales/views/invitations/de.yml @@ -1,7 +1,14 @@ --- de: invitations: + accept_choice: + create_account: Neues Konto erstellen + joined_household: Sie sind dem Haushalt beigetreten. + message: "%{inviter} hat Sie eingeladen, als %{role} beizutreten." + sign_in_existing: Ich habe bereits ein Konto + title: "%{family} beitreten" create: + existing_user_added: Der Benutzer wurde Ihrem Haushalt hinzugefügt. failure: Einladung konnte nicht gesendet werden. success: Einladung erfolgreich gesendet. destroy: @@ -12,6 +19,7 @@ de: email_label: E-Mail-Adresse email_placeholder: E-Mail-Adresse eingeben role_admin: Administrator + role_guest: Gast role_label: Rolle role_member: Mitglied submit: Einladung senden diff --git a/config/locales/views/invitations/es.yml b/config/locales/views/invitations/es.yml index b17da8342..d47edf372 100644 --- a/config/locales/views/invitations/es.yml +++ b/config/locales/views/invitations/es.yml @@ -1,7 +1,14 @@ --- es: invitations: + accept_choice: + create_account: Crear cuenta nueva + joined_household: Te has unido a la unidad familiar. + message: "%{inviter} te ha invitado a unirte como %{role}." + sign_in_existing: Ya tengo una cuenta + title: Unirse a %{family} create: + existing_user_added: El usuario ha sido añadido a tu unidad familiar. failure: No se pudo enviar la invitación success: Invitación enviada con éxito destroy: @@ -12,8 +19,9 @@ es: email_label: Dirección de correo electrónico email_placeholder: Introduce la dirección de correo electrónico role_admin: Administrador + role_guest: Invitado role_label: Rol role_member: Miembro submit: Enviar invitación - subtitle: Envía una invitación para unirte a tu cuenta familiar en Maybe + subtitle: Envía una invitación para unirte a tu cuenta familiar en %{product_name} title: Invitar a alguien diff --git a/config/locales/views/invitations/pl.yml b/config/locales/views/invitations/pl.yml new file mode 100644 index 000000000..61e18dc0b --- /dev/null +++ b/config/locales/views/invitations/pl.yml @@ -0,0 +1,27 @@ +--- +pl: + invitations: + accept_choice: + create_account: Utwórz nowe konto + joined_household: Dołączono do gospodarstwa domowego. + message: "%{inviter} zaprosił(a) Cię jako %{role}." + sign_in_existing: Mam już konto + title: Dołącz do %{family} + create: + existing_user_added: Użytkownik został dodany do Twojego gospodarstwa domowego. + failure: Nie udało się wysłać zaproszenia + success: Zaproszenie zostało wysłane + destroy: + failure: Wystąpił problem podczas usuwania zaproszenia. + not_authorized: Nie masz uprawnień do zarządzania zaproszeniami. + success: Zaproszenie zostało pomyślnie usunięte. + new: + email_label: Adres e-mail + email_placeholder: Wpisz adres e-mail + role_admin: Administrator + role_guest: Gość + role_label: Rola + role_member: Członek + submit: Wyślij zaproszenie + subtitle: Wyślij zaproszenie do dołączenia do Twojego konta %{moniker} w %{product_name} + title: Zaproś kogoś diff --git a/config/locales/views/invite_codes/pl.yml b/config/locales/views/invite_codes/pl.yml new file mode 100644 index 000000000..ef9108f60 --- /dev/null +++ b/config/locales/views/invite_codes/pl.yml @@ -0,0 +1,6 @@ +--- +pl: + invite_codes: + index: + invite_code_description: Wygeneruj nowy kod, aby zobaczyć go tutaj. Wykorzystane kody nie są już wyświetlane. + no_invite_codes: Brak kodów do wyświetlenia diff --git a/config/locales/views/layout/ca.yml b/config/locales/views/layout/ca.yml index 5f6a3b9f8..8868c451c 100644 --- a/config/locales/views/layout/ca.yml +++ b/config/locales/views/layout/ca.yml @@ -2,6 +2,7 @@ ca: layouts: application: + privacy_mode: Alternar mode de privadesa nav: assistant: Assistent budgets: Pressupostos diff --git a/config/locales/views/layout/de.yml b/config/locales/views/layout/de.yml index 55626797c..4738880b3 100644 --- a/config/locales/views/layout/de.yml +++ b/config/locales/views/layout/de.yml @@ -2,6 +2,7 @@ de: layouts: application: + privacy_mode: Datenschutzmodus umschalten nav: assistant: Assistent budgets: Budgets diff --git a/config/locales/views/layout/en.yml b/config/locales/views/layout/en.yml index 702f68746..dfb4ffaed 100644 --- a/config/locales/views/layout/en.yml +++ b/config/locales/views/layout/en.yml @@ -2,6 +2,7 @@ en: layouts: application: + privacy_mode: Toggle privacy mode nav: assistant: Assistant budgets: Budgets diff --git a/config/locales/views/layout/es.yml b/config/locales/views/layout/es.yml index bc14dd208..b1627fd14 100644 --- a/config/locales/views/layout/es.yml +++ b/config/locales/views/layout/es.yml @@ -2,6 +2,7 @@ es: layouts: application: + privacy_mode: Alternar modo de privacidad nav: assistant: Asistente budgets: Presupuestos diff --git a/config/locales/views/layout/fr.yml b/config/locales/views/layout/fr.yml index fb99e913e..54f9cca4b 100644 --- a/config/locales/views/layout/fr.yml +++ b/config/locales/views/layout/fr.yml @@ -2,6 +2,7 @@ fr: layouts: application: + privacy_mode: Activer/désactiver le mode confidentialité nav: assistant: Assistant budgets: Budgets diff --git a/config/locales/views/layout/nb.yml b/config/locales/views/layout/nb.yml index 42aeca716..ebabc16cf 100644 --- a/config/locales/views/layout/nb.yml +++ b/config/locales/views/layout/nb.yml @@ -2,6 +2,7 @@ nb: layouts: application: + privacy_mode: Veksle personvernmodus nav: assistant: Assistent budgets: Budsjett diff --git a/config/locales/views/layout/nl.yml b/config/locales/views/layout/nl.yml index 43bee1c11..9ee7a2b1b 100644 --- a/config/locales/views/layout/nl.yml +++ b/config/locales/views/layout/nl.yml @@ -2,6 +2,7 @@ nl: layouts: application: + privacy_mode: Privacymodus in-/uitschakelen nav: assistant: Assistent budgets: Budgetten diff --git a/config/locales/views/layout/pl.yml b/config/locales/views/layout/pl.yml new file mode 100644 index 000000000..fe70cadf6 --- /dev/null +++ b/config/locales/views/layout/pl.yml @@ -0,0 +1,24 @@ +--- +pl: + layouts: + application: + privacy_mode: Przełącz tryb prywatności + nav: + assistant: Asystent + budgets: Budżety + home: Strona główna + reports: Raporty + transactions: Transakcje + auth: + existing_account: Masz już konto? + no_account: Nowy użytkownik w %{product_name}? + sign_in: Zaloguj się + sign_up: Załóż konto + shared: + footer: + privacy_policy: Polityka prywatności + terms_of_service: Warunki korzystania + trial: + open_demo: Otwórz demo + data_deleted_in_days: Dane zostaną usunięte za %{days} dni + contribute: Wesprzyj diff --git a/config/locales/views/layout/pt-BR.yml b/config/locales/views/layout/pt-BR.yml index 580269edf..2f76fecca 100644 --- a/config/locales/views/layout/pt-BR.yml +++ b/config/locales/views/layout/pt-BR.yml @@ -2,6 +2,7 @@ pt-BR: layouts: application: + privacy_mode: Alternar modo de privacidade nav: assistant: Assistente budgets: Orçamentos diff --git a/config/locales/views/layout/ro.yml b/config/locales/views/layout/ro.yml index fc2f35f0a..15ed02dac 100644 --- a/config/locales/views/layout/ro.yml +++ b/config/locales/views/layout/ro.yml @@ -2,6 +2,7 @@ ro: layouts: application: + privacy_mode: Comutare mod confidențialitate nav: assistant: Asistent budgets: Bugete diff --git a/config/locales/views/layout/tr.yml b/config/locales/views/layout/tr.yml index cff503f7c..d7a482e42 100644 --- a/config/locales/views/layout/tr.yml +++ b/config/locales/views/layout/tr.yml @@ -2,6 +2,7 @@ tr: layouts: application: + privacy_mode: Gizlilik modunu değiştir nav: assistant: Asistan budgets: Bütçeler diff --git a/config/locales/views/layout/zh-CN.yml b/config/locales/views/layout/zh-CN.yml index 2d22156d1..8fc58045e 100644 --- a/config/locales/views/layout/zh-CN.yml +++ b/config/locales/views/layout/zh-CN.yml @@ -3,6 +3,7 @@ zh-CN: layouts: application: + privacy_mode: 切换隐私模式 nav: assistant: 智能助手 budgets: 预算管理 diff --git a/config/locales/views/layout/zh-TW.yml b/config/locales/views/layout/zh-TW.yml index 9c1b4da94..091625bea 100644 --- a/config/locales/views/layout/zh-TW.yml +++ b/config/locales/views/layout/zh-TW.yml @@ -2,6 +2,7 @@ zh-TW: layouts: application: + privacy_mode: 切換隱私模式 nav: assistant: 助手 budgets: 預算 diff --git a/config/locales/views/loans/en.yml b/config/locales/views/loans/en.yml index 33eb76f33..157c01cf2 100644 --- a/config/locales/views/loans/en.yml +++ b/config/locales/views/loans/en.yml @@ -10,6 +10,8 @@ en: rate_type: Rate type term_months: Term (months) term_months_placeholder: '360' + subtype_prompt: Select loan type + subtype_none: None new: title: Enter loan details overview: diff --git a/config/locales/views/loans/pl.yml b/config/locales/views/loans/pl.yml new file mode 100644 index 000000000..2ea423f2d --- /dev/null +++ b/config/locales/views/loans/pl.yml @@ -0,0 +1,23 @@ +--- +pl: + loans: + edit: + edit: Edytuj %{account} + form: + interest_rate: Oprocentowanie + interest_rate_placeholder: '5.25' + initial_balance: Początkowe saldo pożyczki + rate_type: Typ oprocentowania + term_months: Okres (miesiące) + term_months_placeholder: '360' + new: + title: Wprowadź dane pożyczki + overview: + interest_rate: Oprocentowanie + monthly_payment: Rata miesięczna + not_applicable: Nie dotyczy + original_principal: Kapitał początkowy + remaining_principal: Pozostały kapitał + term: Okres + type: Typ + unknown: Nieznane diff --git a/config/locales/views/lunchflow_items/de.yml b/config/locales/views/lunchflow_items/de.yml index e25330471..5aa783ebc 100644 --- a/config/locales/views/lunchflow_items/de.yml +++ b/config/locales/views/lunchflow_items/de.yml @@ -1,62 +1,145 @@ +--- de: lunchflow_items: create: - success: Lunch-Flow-Verbindung erfolgreich erstellt + success: Lunch‑Flow-Verbindung erfolgreich erstellt destroy: - success: Lunch-Flow-Verbindung entfernt + success: Lunch‑Flow-Verbindung entfernt index: - title: Lunch-Flow-Verbindungen + title: Lunch‑Flow-Verbindungen loading: - loading_message: Lunch-Flow-Konten werden geladen... + loading_message: Lunch‑Flow-Konten werden geladen... loading_title: Wird geladen link_accounts: all_already_linked: one: "Das ausgewählte Konto (%{names}) ist bereits verknüpft" other: "Alle %{count} ausgewählten Konten sind bereits verknüpft: %{names}" api_error: "API-Fehler: %{message}" + invalid_account_names: + one: "Konto mit leerem Namen kann nicht verknüpft werden" + other: "%{count} Konten mit leerem Namen können nicht verknüpft werden" link_failed: Konten konnten nicht verknüpft werden no_accounts_selected: Bitte wähle mindestens ein Konto aus + partial_invalid: "%{created_count} Konto/Konten verknüpft, %{already_linked_count} waren bereits verknüpft, %{invalid_count} hatten ungültige Namen" partial_success: "%{created_count} Konto/Konten erfolgreich verknüpft. %{already_linked_count} Konto/Konten waren bereits verknüpft: %{already_linked_names}" success: one: "%{count} Konto erfolgreich verknüpft" other: "%{count} Konten erfolgreich verknüpft" lunchflow_item: + accounts_need_setup: Konten müssen eingerichtet werden delete: Verbindung löschen deletion_in_progress: Löschung wird durchgeführt... error: Fehler no_accounts_description: Diese Verbindung enthält derzeit keine verknüpften Konten. no_accounts_title: Keine Konten + setup_action: Neue Konten einrichten + setup_description: "%{linked} von %{total} Konten verknüpft. Wähle die richtigen Kontotypen für deine neu importierten Lunch‑Flow-Konten." + setup_needed: Neue Konten bereit zur Einrichtung status: "Vor %{timestamp} synchronisiert" status_never: Noch nie synchronisiert + status_with_summary: "Zuletzt vor %{timestamp} synchronisiert • %{summary}" syncing: Wird synchronisiert... + total: Gesamt + unlinked: Nicht verknüpft select_accounts: accounts_selected: Konten ausgewählt api_error: "API-Fehler: %{message}" cancel: Abbrechen + configure_name_in_lunchflow: Import nicht möglich – bitte Kontoname in Lunch‑Flow konfigurieren description: Wähle die Konten aus, die du mit deinem %{product_name}-Konto verknüpfen möchtest. link_accounts: Ausgewählte Konten verknüpfen no_accounts_found: Keine Konten gefunden. Bitte überprüfe deine API-Key-Konfiguration. - no_api_key: Lunch-Flow-API-Schlüssel ist nicht konfiguriert. Bitte konfiguriere ihn in den Einstellungen. - title: Lunch-Flow-Konten auswählen + no_api_key: Lunch‑Flow-API-Schlüssel ist nicht konfiguriert. Bitte konfiguriere ihn in den Einstellungen. + no_name_placeholder: "(Kein Name)" + title: Lunch‑Flow-Konten auswählen select_existing_account: account_already_linked: Dieses Konto ist bereits mit einem Anbieter verknüpft - all_accounts_already_linked: Alle Lunch-Flow-Konten sind bereits verknüpft + all_accounts_already_linked: Alle Lunch‑Flow-Konten sind bereits verknüpft api_error: "API-Fehler: %{message}" cancel: Abbrechen - description: Wähle ein Lunch-Flow-Konto aus, um es mit diesem Konto zu verknüpfen. Transaktionen werden automatisch synchronisiert und doppelte Einträge entfernt. + configure_name_in_lunchflow: Import nicht möglich – bitte Kontoname in Lunch‑Flow konfigurieren + description: Wähle ein Lunch‑Flow-Konto aus, um es mit diesem Konto zu verknüpfen. Transaktionen werden automatisch synchronisiert und doppelte Einträge entfernt. link_account: Konto verknüpfen no_account_specified: Kein Konto angegeben - no_accounts_found: Keine Lunch-Flow-Konten gefunden. Bitte überprüfe deine API-Key-Konfiguration. - no_api_key: Lunch-Flow-API-Schlüssel ist nicht konfiguriert. Bitte konfiguriere ihn in den Einstellungen. - title: "%{account_name} mit Lunch Flow verknüpfen" + no_accounts_found: Keine Lunch‑Flow-Konten gefunden. Bitte überprüfe deine API-Key-Konfiguration. + no_api_key: Lunch‑Flow-API-Schlüssel ist nicht konfiguriert. Bitte konfiguriere ihn in den Einstellungen. + no_name_placeholder: "(Kein Name)" + title: "%{account_name} mit Lunch‑Flow verknüpfen" link_existing_account: account_already_linked: Dieses Konto ist bereits mit einem Anbieter verknüpft api_error: "API-Fehler: %{message}" - lunchflow_account_already_linked: Dieses Lunch-Flow-Konto ist bereits mit einem anderen Konto verknüpft - lunchflow_account_not_found: Lunch-Flow-Konto nicht gefunden + invalid_account_name: Konto mit leerem Namen kann nicht verknüpft werden + lunchflow_account_already_linked: Dieses Lunch‑Flow-Konto ist bereits mit einem anderen Konto verknüpft + lunchflow_account_not_found: Lunch‑Flow-Konto nicht gefunden missing_parameters: Erforderliche Parameter fehlen - success: "%{account_name} erfolgreich mit Lunch Flow verknüpft" + success: "%{account_name} erfolgreich mit Lunch‑Flow verknüpft" + setup_accounts: + account_type_label: "Kontotyp:" + all_accounts_linked: "Alle deine Lunch‑Flow-Konten sind bereits eingerichtet." + api_error: "API-Fehler: %{message}" + fetch_failed: "Konten konnten nicht geladen werden" + no_accounts_to_setup: "Keine Konten zum Einrichten" + no_api_key: "Der Lunch‑Flow-API-Schlüssel ist nicht konfiguriert. Bitte prüfe deine Verbindungseinstellungen." + account_types: + skip: Dieses Konto überspringen + depository: Giro- oder Sparkonto + credit_card: Kreditkarte + investment: Anlagekonto + loan: Darlehen oder Hypothek + other_asset: Sonstiges Vermögen + subtype_labels: + depository: "Konto-Untertyp:" + credit_card: "" + investment: "Anlagetyp:" + loan: "Darlehenstyp:" + other_asset: "" + subtype_messages: + credit_card: "Kreditkarten werden automatisch als Kreditkartenkonten eingerichtet." + other_asset: "Für sonstiges Vermögen sind keine weiteren Optionen nötig." + subtypes: + depository: + checking: Girokonto + savings: Sparkonto + hsa: Health Savings Account + cd: Festgeld + money_market: Geldmarkt + investment: + brokerage: Brokerage + pension: Pension + retirement: Ruhestand + "401k": "401(k)" + roth_401k: "Roth 401(k)" + "403b": "403(b)" + tsp: Thrift Savings Plan + "529_plan": "529 Plan" + hsa: Health Savings Account + mutual_fund: Fonds + ira: Traditionelles IRA + roth_ira: Roth IRA + angel: Business Angel + loan: + mortgage: Hypothek + student: Studienkredit + auto: Autokredit + other: Sonstiges Darlehen + balance: Saldo + cancel: Abbrechen + choose_account_type: "Wähle den richtigen Kontotyp für jedes Lunch‑Flow-Konto:" + create_accounts: Konten anlegen + creating_accounts: Konten werden angelegt... + historical_data_range: "Historischer Datenbereich:" + subtitle: Wähle die richtigen Kontotypen für deine importierten Konten + sync_start_date_help: Wähle, wie weit die Buchungshistorie zurück synchronisiert werden soll. Maximal sind 3 Jahre verfügbar. + sync_start_date_label: "Buchungen synchronisieren ab:" + title: Lunch‑Flow-Konten einrichten + complete_account_setup: + all_skipped: "Alle Konten wurden übersprungen. Es wurden keine Konten angelegt." + creation_failed: "Konten konnten nicht angelegt werden: %{error}" + no_accounts: "Keine Konten zum Einrichten." + success: + one: "%{count} Konto erfolgreich angelegt." + other: "%{count} Konten erfolgreich angelegt." sync: success: Synchronisierung gestartet update: - success: Lunch-Flow-Verbindung aktualisiert + success: Lunch‑Flow-Verbindung aktualisiert diff --git a/config/locales/views/lunchflow_items/es.yml b/config/locales/views/lunchflow_items/es.yml index a64a308dc..c2889172e 100644 --- a/config/locales/views/lunchflow_items/es.yml +++ b/config/locales/views/lunchflow_items/es.yml @@ -15,49 +15,129 @@ es: one: "La cuenta seleccionada (%{names}) ya está vinculada" other: "Todas las %{count} cuentas seleccionadas ya están vinculadas: %{names}" api_error: "Error de API: %{message}" + invalid_account_names: + one: "No se puede vincular una cuenta con el nombre en blanco" + other: "No se pueden vincular %{count} cuentas con nombres en blanco" link_failed: Error al vincular cuentas no_accounts_selected: Por favor, selecciona al menos una cuenta + partial_invalid: "Se han vinculado %{created_count} cuenta(s) con éxito, %{already_linked_count} ya estaban vinculadas, %{invalid_count} cuenta(s) tenían nombres no válidos" partial_success: "%{created_count} cuenta(s) vinculada(s) con éxito. %{already_linked_count} cuenta(s) ya estaban vinculadas: %{already_linked_names}" success: one: "%{count} cuenta vinculada con éxito" other: "%{count} cuentas vinculadas con éxito" lunchflow_item: + accounts_need_setup: Las cuentas necesitan configuración delete: Eliminar conexión deletion_in_progress: eliminación en progreso... error: Error no_accounts_description: Esta conexión aún no tiene cuentas vinculadas. no_accounts_title: Sin cuentas + setup_action: Configurar nuevas cuentas + setup_description: "%{linked} de %{total} cuentas vinculadas. Elige los tipos de cuenta para tus cuentas de Lunch Flow recién importadas." + setup_needed: Nuevas cuentas listas para configurar status: "Sincronizado hace %{timestamp}" status_never: Nunca sincronizado + status_with_summary: "Sincronizado hace %{timestamp} • %{summary}" syncing: Sincronizando... + total: Total + unlinked: Desvinculadas select_accounts: accounts_selected: cuentas seleccionadas api_error: "Error de API: %{message}" cancel: Cancelar - description: Selecciona las cuentas que deseas vincular a tu cuenta de Sure. + configure_name_in_lunchflow: "No se puede importar: por favor, configura el nombre de la cuenta en Lunch Flow" + description: Selecciona las cuentas que deseas vincular a tu cuenta de %{product_name}. link_accounts: Vincular cuentas seleccionadas no_accounts_found: No se encontraron cuentas. Por favor, verifica la configuración de tu clave API. no_api_key: La clave API de Lunch Flow no está configurada. Por favor, configúrala en Configuración. + no_name_placeholder: "(Sin nombre)" title: Seleccionar cuentas de Lunch Flow select_existing_account: account_already_linked: Esta cuenta ya está vinculada a un proveedor all_accounts_already_linked: Todas las cuentas de Lunch Flow ya están vinculadas api_error: "Error de API: %{message}" cancel: Cancelar + configure_name_in_lunchflow: "No se puede importar: por favor, configura el nombre de la cuenta en Lunch Flow" description: Selecciona una cuenta de Lunch Flow para vincular con esta cuenta. Las transacciones se sincronizarán y desduplicarán automáticamente. link_account: Vincular cuenta no_account_specified: No se especificó ninguna cuenta no_accounts_found: No se encontraron cuentas de Lunch Flow. Por favor, verifica la configuración de tu clave API. no_api_key: La clave API de Lunch Flow no está configurada. Por favor, configúrala en Configuración. + no_name_placeholder: "(Sin nombre)" title: "Vincular %{account_name} con Lunch Flow" link_existing_account: account_already_linked: Esta cuenta ya está vinculada a un proveedor api_error: "Error de API: %{message}" + invalid_account_name: No se puede vincular una cuenta con el nombre en blanco lunchflow_account_already_linked: Esta cuenta de Lunch Flow ya está vinculada a otra cuenta lunchflow_account_not_found: Cuenta de Lunch Flow no encontrada missing_parameters: Faltan parámetros requeridos success: "%{account_name} vinculada con Lunch Flow con éxito" + setup_accounts: + account_type_label: "Tipo de cuenta:" + all_accounts_linked: "Todas tus cuentas de Lunch Flow ya han sido configuradas." + api_error: "Error de API: %{message}" + fetch_failed: "Error al obtener las cuentas" + no_accounts_to_setup: "No hay cuentas para configurar" + no_api_key: "La clave API de Lunch Flow no está configurada. Por favor, comprueba los ajustes de conexión." + account_types: + skip: Omitir esta cuenta + depository: Cuenta corriente o de ahorro + credit_card: Tarjeta de crédito + investment: Cuenta de inversión + loan: Préstamo o hipoteca + other_asset: Otro activo + subtype_labels: + depository: "Subtipo de cuenta:" + credit_card: "" + investment: "Tipo de inversión:" + loan: "Tipo de préstamo:" + other_asset: "" + subtype_messages: + credit_card: "Las tarjetas de crédito se configurarán automáticamente como cuentas de tarjeta de crédito." + other_asset: "No se necesitan opciones adicionales para otros activos." + subtypes: + depository: + checking: Corriente + savings: Ahorros + hsa: Cuenta de ahorros para la salud (HSA) + cd: Certificado de depósito + money_market: Mercado monetario + investment: + brokerage: Bróker + pension: Plan de pensiones + retirement: Jubilación + "401k": "401(k)" + roth_401k: "Roth 401(k)" + "403b": "403(b)" + tsp: Plan de ahorro TSP + "529_plan": Plan 529 + hsa: Cuenta de ahorros para la salud (HSA) + mutual_fund: Fondo de inversión + ira: IRA tradicional + roth_ira: Roth IRA + angel: Inversión Angel + loan: + mortgage: Hipoteca + student: Préstamo estudiantil + auto: Préstamo de coche + other: Otro préstamo + balance: Saldo + cancel: Cancelar + choose_account_type: "Elige el tipo de cuenta correcto para cada cuenta de Lunch Flow:" + create_accounts: Crear cuentas + creating_accounts: Creando cuentas... + historical_data_range: "Rango de datos históricos:" + subtitle: Elige los tipos de cuenta correctos para tus cuentas importadas + sync_start_date_help: Selecciona cuánto tiempo atrás deseas sincronizar el historial de transacciones. Máximo 3 años de historial disponibles. + sync_start_date_label: "Empezar a sincronizar transacciones desde:" + title: Configura tus cuentas de Lunch Flow + complete_account_setup: + all_skipped: "Se omitieron todas las cuentas. No se creó ninguna cuenta." + creation_failed: "Error al crear las cuentas: %{error}" + no_accounts: "No hay cuentas para configurar." + success: "Se han creado %{count} cuenta(s) con éxito." sync: success: Sincronización iniciada update: - success: Conexión con Lunch Flow actualizada + success: Conexión con Lunch Flow actualizada \ No newline at end of file diff --git a/config/locales/views/lunchflow_items/pl.yml b/config/locales/views/lunchflow_items/pl.yml new file mode 100644 index 000000000..8d243a3f9 --- /dev/null +++ b/config/locales/views/lunchflow_items/pl.yml @@ -0,0 +1,153 @@ +--- +pl: + lunchflow_items: + create: + success: Połączenie Lunch Flow zostało pomyślnie utworzone + destroy: + success: Połączenie Lunch Flow zostało usunięte + index: + title: Połączenia Lunch Flow + loading: + loading_message: Ładowanie kont Lunch Flow... + loading_title: Ładowanie + link_accounts: + all_already_linked: + one: Wybrane konto (%{names}) jest już połączone + few: 'Wszystkie %{count} wybrane konta są już połączone: %{names}' + many: 'Wszystkie %{count} wybranych kont jest już połączonych: %{names}' + other: 'Wszystkie %{count} wybrane konta są już połączone: %{names}' + api_error: 'Błąd API: %{message}' + invalid_account_names: + one: Nie można połączyć konta bez nazwy + few: Nie można połączyć %{count} kont bez nazwy + many: Nie można połączyć %{count} kont bez nazwy + other: Nie można połączyć %{count} kont bez nazwy + link_failed: Nie udało się połączyć kont + no_accounts_selected: Wybierz co najmniej jedno konto + partial_invalid: Pomyślnie połączono %{created_count} konto(a), %{already_linked_count} było już połączonych, %{invalid_count} konto(a) miało nieprawidłowe nazwy + partial_success: 'Pomyślnie połączono %{created_count} konto(a). %{already_linked_count} konto(a) było już połączonych: %{already_linked_names}' + success: + one: Pomyślnie połączono %{count} konto + few: Pomyślnie połączono %{count} konta + many: Pomyślnie połączono %{count} kont + other: Pomyślnie połączono %{count} kont + lunchflow_item: + accounts_need_setup: Konta wymagają konfiguracji + delete: Usuń połączenie + deletion_in_progress: usuwanie w toku... + error: Błąd + no_accounts_description: To połączenie nie ma jeszcze żadnych połączonych kont. + no_accounts_title: Brak kont + setup_action: Skonfiguruj nowe konta + setup_description: "%{linked} z %{total} kont połączonych. Wybierz typy kont dla nowo zaimportowanych kont Lunch Flow." + setup_needed: Nowe konta gotowe do konfiguracji + status: Zsynchronizowano %{timestamp} temu + status_never: Nigdy nie synchronizowano + status_with_summary: Ostatnia synchronizacja %{timestamp} temu • %{summary} + syncing: Synchronizacja... + total: Łącznie + unlinked: Niepodłączone + select_accounts: + accounts_selected: wybranych kont + api_error: 'Błąd API: %{message}' + cancel: Anuluj + configure_name_in_lunchflow: Nie można zaimportować — skonfiguruj nazwę konta w Lunchflow + description: Wybierz konta, które chcesz połączyć z kontem %{product_name}. + link_accounts: Połącz wybrane konta + no_accounts_found: Nie znaleziono kont. Sprawdź konfigurację klucza API. + no_api_key: Klucz API Lunch Flow nie jest skonfigurowany. Skonfiguruj go w Ustawieniach. + no_name_placeholder: "(Brak nazwy)" + title: Wybierz konta Lunch Flow + select_existing_account: + account_already_linked: To konto jest już połączone z dostawcą + all_accounts_already_linked: Wszystkie konta Lunch Flow są już połączone + api_error: 'Błąd API: %{message}' + cancel: Anuluj + configure_name_in_lunchflow: Nie można zaimportować — skonfiguruj nazwę konta w Lunchflow + description: Wybierz konto Lunch Flow do połączenia z tym kontem. Transakcje będą synchronizowane i deduplikowane automatycznie. + link_account: Połącz konto + no_account_specified: Nie podano konta + no_accounts_found: Nie znaleziono kont Lunch Flow. Sprawdź konfigurację klucza API. + no_api_key: Klucz API Lunch Flow nie jest skonfigurowany. Skonfiguruj go w Ustawieniach. + no_name_placeholder: "(Brak nazwy)" + title: Połącz %{account_name} z Lunch Flow + link_existing_account: + account_already_linked: To konto jest już połączone z dostawcą + api_error: 'Błąd API: %{message}' + invalid_account_name: Nie można połączyć konta bez nazwy + lunchflow_account_already_linked: To konto Lunch Flow jest już połączone z innym kontem + lunchflow_account_not_found: Nie znaleziono konta Lunch Flow + missing_parameters: Brak wymaganych parametrów + success: Pomyślnie połączono %{account_name} z Lunch Flow + setup_accounts: + account_type_label: 'Typ konta:' + all_accounts_linked: Wszystkie Twoje konta Lunch Flow są już skonfigurowane. + api_error: 'Błąd API: %{message}' + fetch_failed: Nie udało się pobrać kont + no_accounts_to_setup: Brak kont do konfiguracji + no_api_key: Klucz API Lunch Flow nie jest skonfigurowany. Sprawdź ustawienia połączenia. + account_types: + skip: Pomiń to konto + depository: Konto bieżące lub oszczędnościowe + credit_card: Karta kredytowa + investment: Konto inwestycyjne + loan: Pożyczka lub kredyt hipoteczny + other_asset: Inne aktywa + subtype_labels: + depository: 'Podtyp konta:' + credit_card: 'Podtyp karty kredytowej:' + investment: 'Typ inwestycji:' + loan: 'Typ pożyczki:' + other_asset: 'Podtyp aktywa:' + subtype_messages: + credit_card: Karty kredytowe zostaną automatycznie skonfigurowane jako konta kart kredytowych. + other_asset: Dla innych aktywów nie są potrzebne dodatkowe opcje. + subtypes: + depository: + checking: Konto bieżące + savings: Oszczędnościowe + hsa: Konto oszczędnościowe na cele zdrowotne + cd: Lokata terminowa + money_market: Rynek pieniężny + investment: + brokerage: Maklerskie + pension: Emerytura + retirement: Emerytalne + 401k: 401(k) + roth_401k: Roth 401(k) + 403b: 403(b) + tsp: Plan oszczędnościowy Thrift + 529_plan: 529 Plan + hsa: Konto oszczędnościowe na cele zdrowotne + mutual_fund: Fundusz inwestycyjny + ira: Tradycyjne IRA + roth_ira: Roth IRA + angel: Anielska + loan: + mortgage: Kredyt hipoteczny + student: Pożyczka studencka + auto: Pożyczka na samochód + other: Inna pożyczka + balance: Saldo + cancel: Anuluj + choose_account_type: 'Wybierz poprawny typ dla każdego konta Lunch Flow:' + create_accounts: Utwórz konta + creating_accounts: Tworzenie kont... + historical_data_range: 'Zakres danych historycznych:' + subtitle: Wybierz poprawne typy dla importowanych kont + sync_start_date_help: Wybierz, jak daleko wstecz chcesz synchronizować historię transakcji. Dostępne są maksymalnie 3 lata historii. + sync_start_date_label: 'Synchronizuj transakcje od:' + title: Skonfiguruj swoje konta Lunch Flow + complete_account_setup: + all_skipped: Wszystkie konta zostały pominięte. Nie utworzono żadnych kont. + creation_failed: 'Nie udało się utworzyć kont: %{error}' + no_accounts: Brak kont do skonfigurowania. + success: + one: Pomyślnie utworzono %{count} konto. + few: Pomyślnie utworzono %{count} konta. + many: Pomyślnie utworzono %{count} kont. + other: Pomyślnie utworzono %{count} konta. + sync: + success: Rozpoczęto synchronizację + update: + success: Połączenie Lunch Flow zostało zaktualizowane diff --git a/config/locales/views/merchants/de.yml b/config/locales/views/merchants/de.yml index 2efbc7dc5..f0da0c60e 100644 --- a/config/locales/views/merchants/de.yml +++ b/config/locales/views/merchants/de.yml @@ -6,19 +6,26 @@ de: success: Neuer Händler erfolgreich erstellt destroy: success: Händler erfolgreich gelöscht + unlinked_success: Händler von deinen Transaktionen entfernt edit: title: Händler bearbeiten form: name_placeholder: Händlername + website_placeholder: Website (z. B. starbucks.com) + website_hint: Gib die Website des Händlers ein, um dessen Logo automatisch anzuzeigen index: empty: Noch keine Händler vorhanden new: Neuer Händler + merge: Händler zusammenführen title: Händler family_title: Händler der Familie family_empty: Noch keine Händler der Familie vorhanden provider_title: Anbieter-Händler provider_empty: Noch keine Anbieter-Händler mit dieser Familie verbunden provider_read_only: Anbieter-Händler werden von deinen verbundenen Institutionen synchronisiert. Sie können hier nicht bearbeitet werden. + provider_info: Diese Händler wurden automatisch von deinen Bankverbindungen oder der KI erkannt. Du kannst sie bearbeiten, um deine eigene Kopie zu erstellen, oder sie entfernen, um sie von deinen Transaktionen zu trennen. + unlinked_title: Kürzlich getrennt + unlinked_info: Diese Händler wurden kürzlich von deinen Transaktionen entfernt. Sie verschwinden nach 30 Tagen aus dieser Liste, sofern sie nicht erneut einer Transaktion zugewiesen werden. table: merchant: Händler actions: Aktionen @@ -30,7 +37,26 @@ de: confirm_title: Händler löschen delete: Händler löschen edit: Händler bearbeiten + merge: + title: Händler zusammenführen + description: Wähle einen Zielhändler und die Händler, die darin zusammengeführt werden sollen. Alle Transaktionen der zusammengeführten Händler werden dem Ziel zugewiesen. + target_label: Zusammenführen in (Ziel) + select_target: Zielhändler auswählen … + sources_label: Händler zum Zusammenführen + sources_hint: Die ausgewählten Händler werden in den Zielhändler zusammengeführt. Familienhändler werden gelöscht, Anbieter-Händler werden getrennt. + submit: Ausgewählte zusammenführen new: title: Neuer Händler + perform_merge: + success: "%{count} Händler erfolgreich zusammengeführt" + no_merchants_selected: Keine Händler zum Zusammenführen ausgewählt + target_not_found: Zielhändler nicht gefunden + invalid_merchants: Ungültige Händler ausgewählt + provider_merchant: + edit: Bearbeiten + remove: Entfernen + remove_confirm_title: Händler entfernen? + remove_confirm_body: Bist du sicher, dass du %{name} entfernen möchtest? Dadurch werden alle zugehörigen Transaktionen von diesem Händler getrennt, der Händler selbst wird nicht gelöscht. update: success: Händler erfolgreich aktualisiert + converted_success: Händler umgewandelt und erfolgreich aktualisiert diff --git a/config/locales/views/merchants/en.yml b/config/locales/views/merchants/en.yml index efd51ba76..3b4907cf4 100644 --- a/config/locales/views/merchants/en.yml +++ b/config/locales/views/merchants/en.yml @@ -24,6 +24,10 @@ en: provider_empty: "No provider merchants linked to this %{moniker} yet" provider_read_only: Provider merchants are synced from your connected institutions. They cannot be edited here. provider_info: These merchants were automatically detected by your bank connections or AI. You can edit them to create your own copy, or remove them to unlink from your transactions. + enhance_info: + one: "%{count} provider merchant is missing website information. Enhance with AI to detect websites, display logos, and merge duplicate merchants." + other: "%{count} provider merchants are missing website information. Enhance with AI to detect websites, display logos, and merge duplicate merchants." + enhance_button: Enhance with AI unlinked_title: Recently unlinked unlinked_info: These merchants were recently removed from your transactions. They will disappear from this list after 30 days unless re-assigned to a transaction. table: @@ -57,6 +61,9 @@ en: remove: Remove remove_confirm_title: Remove merchant? remove_confirm_body: Are you sure you want to remove %{name}? This will unlink all associated transactions from this merchant but will not delete the merchant itself. + enhance: + success: Provider merchant enhancement started. Merchants will be enhanced and duplicates merged shortly. + already_running: Enhancement is already in progress. Please wait for it to finish. update: success: Merchant updated successfully converted_success: Merchant converted and updated successfully diff --git a/config/locales/views/merchants/es.yml b/config/locales/views/merchants/es.yml index f769dfea1..fb04644f5 100644 --- a/config/locales/views/merchants/es.yml +++ b/config/locales/views/merchants/es.yml @@ -6,31 +6,57 @@ es: success: Nuevo comercio creado con éxito destroy: success: Comercio eliminado con éxito + unlinked_success: Comercio eliminado de tus transacciones edit: title: Editar comercio form: name_placeholder: Nombre del comercio + website_placeholder: Sitio web (ej. starbucks.com) + website_hint: Introduce el sitio web del comercio para mostrar automáticamente su logotipo index: empty: Aún no hay comercios new: Nuevo comercio + merge: Fusionar comercios title: Comercios - family_title: Comercios familiares - family_empty: Aún no hay comercios familiares + family_title: "Comercios de %{moniker}" + family_empty: "Aún no hay comercios de %{moniker}" provider_title: Comercios del proveedor - provider_empty: Ningún comercio del proveedor vinculado a esta familia todavía + provider_empty: "Aún no hay comercios del proveedor vinculados a %{moniker}" provider_read_only: Los comercios del proveedor se sincronizan desde tus instituciones conectadas. No se pueden editar aquí. + provider_info: Estos comercios han sido detectados automáticamente por tus conexiones bancarias o por IA. Puedes editarlos para crear tu propia copia o eliminarlos para desvincularlos de tus transacciones. + unlinked_title: Desvinculados recientemente + unlinked_info: Estos comercios se han eliminado recientemente de tus transacciones. Desaparecerán de esta lista tras 30 días, a menos que se vuelvan a asignar a una transacción. table: merchant: Comercio actions: Acciones source: Origen merchant: confirm_accept: Eliminar comercio - confirm_body: ¿Estás seguro de que deseas eliminar este comercio? Eliminar este comercio + confirm_body: ¿Estás seguro de que deseas eliminar este comercio? Eliminar este comercio desvinculará todas las transacciones asociadas y puede afectar a tus informes. confirm_title: ¿Eliminar comercio? delete: Eliminar comercio edit: Editar comercio + merge: + title: Fusionar comercios + description: Selecciona un comercio de destino y los comercios que deseas fusionar en él. Todas las transacciones de los comercios fusionados se reasignarán al de destino. + target_label: Fusionar en (destino) + select_target: Seleccionar comercio de destino... + sources_label: Comercios a fusionar + sources_hint: Los comercios seleccionados se fusionarán en el de destino. Los comercios familiares se eliminarán y los de proveedores se desvincularán. + submit: Fusionar seleccionados new: title: Nuevo comercio + perform_merge: + success: Se han fusionado %{count} comercios correctamente + no_merchants_selected: No se han seleccionado comercios para fusionar + target_not_found: No se ha encontrado el comercio de destino + invalid_merchants: Se han seleccionado comercios no válidos + provider_merchant: + edit: Editar + remove: Eliminar + remove_confirm_title: ¿Eliminar comercio? + remove_confirm_body: ¿Estás seguro de que quieres eliminar %{name}? Esto desvinculará todas las transacciones asociadas a este comercio, pero no eliminará el comercio en sí. update: success: Comercio actualizado con éxito + converted_success: Comercio convertido y actualizado con éxito diff --git a/config/locales/views/merchants/pl.yml b/config/locales/views/merchants/pl.yml new file mode 100644 index 000000000..c95e40896 --- /dev/null +++ b/config/locales/views/merchants/pl.yml @@ -0,0 +1,70 @@ +--- +pl: + family_merchants: + create: + error: 'Błąd podczas tworzenia kontrahenta: %{error}' + success: Nowy kontrahent został pomyślnie utworzony + destroy: + success: Kontrahent został pomyślnie usunięty + unlinked_success: Kontrahent został odłączony od Twoich transakcji + edit: + title: Edytuj kontrahenta + form: + name_placeholder: Nazwa kontrahenta + website_placeholder: Strona internetowa (np. starbucks.com) + website_hint: Wprowadź stronę internetową kontrahenta, aby automatycznie wyświetlać jego logo + index: + empty: Brak kontrahentów + new: Nowy kontrahent + merge: Scal kontrahentów + title: Kontrahenci + family_title: "Kontrahenci %{moniker}" + family_empty: Brak kontrahentów %{moniker} + provider_title: Kontrahenci od dostawcy + provider_empty: Brak kontrahentów dostawcy połączonych z tym %{moniker} + provider_read_only: Kontrahenci dostawcy są synchronizowani z połączonych instytucji. Nie można ich tutaj edytować. + provider_info: Ci kontrahenci zostali automatycznie wykryci przez połączenia bankowe lub AI. Możesz ich edytować, aby utworzyć własną kopię, albo usunąć, aby odłączyć ich od transakcji. + enhance_info: + one: "%{count} kontrahentowi dostawcy brakuje informacji o stronie internetowej. Ulepsz dane z pomocą AI, aby wykryć strony, wyświetlić loga i scalić duplikaty kontrahentów." + few: "%{count} kontrahentom dostawcy brakuje informacji o stronie internetowej. Ulepsz dane z pomocą AI, aby wykryć strony, wyświetlić loga i scalić duplikaty kontrahentów." + many: "%{count} kontrahentom dostawcy brakuje informacji o stronie internetowej. Ulepsz dane z pomocą AI, aby wykryć strony, wyświetlić loga i scalić duplikaty kontrahentów." + other: "%{count} kontrahentom dostawcy brakuje informacji o stronie internetowej. Ulepsz dane z pomocą AI, aby wykryć strony, wyświetlić loga i scalić duplikaty kontrahentów." + enhance_button: Ulepsz z pomocą AI + unlinked_title: Ostatnio odłączone + unlinked_info: Ci kontrahenci zostali niedawno odłączeni od Twoich transakcji. Znikną z tej listy po 30 dniach, chyba że zostaną ponownie przypisani do transakcji. + table: + merchant: Kontrahent + actions: Akcje + source: Źródło + merchant: + confirm_accept: Usuń kontrahenta + confirm_body: Czy na pewno chcesz usunąć tego kontrahenta? Usunięcie odłączy wszystkie powiązane transakcje i może wpłynąć na raporty. + confirm_title: Usunąć kontrahenta? + delete: Usuń kontrahenta + edit: Edytuj kontrahenta + merge: + title: Scal kontrahentów + description: Wybierz kontrahenta docelowego i kontrahentów do scalenia z nim. Wszystkie transakcje scalonych kontrahentów zostaną przypisane do celu. + target_label: Scal do (docelowy) + select_target: Wybierz kontrahenta docelowego... + sources_label: Kontrahenci do scalenia + sources_hint: Wybrani kontrahenci zostaną scaleni z celem. Kontrahenci rodziny zostaną usunięci, a kontrahenci dostawcy odłączeni. + submit: Scal wybrane + new: + title: Nowy kontrahent + perform_merge: + success: Pomyślnie scalono %{count} kontrahentów + no_merchants_selected: Nie wybrano kontrahentów do scalenia + target_not_found: Nie znaleziono kontrahenta docelowego + invalid_merchants: Wybrano nieprawidłowych kontrahentów + provider_merchant: + edit: Edytuj + remove: Usuń + remove_confirm_title: Usunąć kontrahenta? + remove_confirm_body: Czy na pewno chcesz usunąć %{name}? To odłączy wszystkie powiązane transakcje od tego kontrahenta, ale nie usunie samego kontrahenta. + enhance: + success: Rozpoczęto ulepszanie kontrahentów dostawcy. Kontrahenci zostaną ulepszeni, a duplikaty wkrótce scalone. + already_running: Ulepszanie już trwa. Poczekaj na zakończenie. + update: + success: Kontrahent został pomyślnie zaktualizowany + converted_success: Kontrahent został pomyślnie przekonwertowany i zaktualizowany diff --git a/config/locales/views/mercury_items/de.yml b/config/locales/views/mercury_items/de.yml new file mode 100644 index 000000000..85c1a15d9 --- /dev/null +++ b/config/locales/views/mercury_items/de.yml @@ -0,0 +1,147 @@ +--- +de: + mercury_items: + create: + success: "Mercury-Verbindung erfolgreich erstellt" + destroy: + success: "Mercury-Verbindung entfernt" + index: + title: "Mercury-Verbindungen" + loading: + loading_message: "Mercury-Konten werden geladen..." + loading_title: "Laden" + link_accounts: + all_already_linked: + one: "Das ausgewählte Konto (%{names}) ist bereits verknüpft" + other: "Alle %{count} ausgewählten Konten sind bereits verknüpft: %{names}" + api_error: "API-Fehler: %{message}" + invalid_account_names: + one: "Konto mit leerem Namen kann nicht verknüpft werden" + other: "%{count} Konten mit leeren Namen können nicht verknüpft werden" + link_failed: "Konten konnten nicht verknüpft werden" + no_accounts_selected: "Bitte wählen Sie mindestens ein Konto aus" + no_api_token: "Mercury API-Token nicht gefunden. Bitte in den Anbieter-Einstellungen konfigurieren." + partial_invalid: "Erfolgreich %{created_count} Konto/Konten verknüpft, %{already_linked_count} waren bereits verknüpft, %{invalid_count} Konto/Konten hatten ungültige Namen" + partial_success: "Erfolgreich %{created_count} Konto/Konten verknüpft. %{already_linked_count} Konto/Konten waren bereits verknüpft: %{already_linked_names}" + success: + one: "Erfolgreich %{count} Konto verknüpft" + other: "Erfolgreich %{count} Konten verknüpft" + mercury_item: + accounts_need_setup: "Konten benötigen Einrichtung" + delete: "Verbindung löschen" + deletion_in_progress: "Löschung läuft..." + error: "Fehler" + no_accounts_description: "Diese Verbindung hat noch keine verknüpften Konten." + no_accounts_title: "Keine Konten" + setup_action: "Neue Konten einrichten" + setup_description: "%{linked} von %{total} Konten verknüpft. Wählen Sie Kontotypen für Ihre neu importierten Mercury-Konten." + setup_needed: "Neue Konten bereit zur Einrichtung" + status: "Vor %{timestamp} synchronisiert" + status_never: "Noch nie synchronisiert" + status_with_summary: "Zuletzt vor %{timestamp} synchronisiert – %{summary}" + syncing: "Synchronisiere..." + total: "Gesamt" + unlinked: "Nicht verknüpft" + select_accounts: + accounts_selected: "Konten ausgewählt" + api_error: "API-Fehler: %{message}" + cancel: "Abbrechen" + configure_name_in_mercury: "Import nicht möglich – bitte Kontoname in Mercury konfigurieren" + description: "Wählen Sie die Konten aus, die Sie mit Ihrem %{product_name}-Konto verknüpfen möchten." + link_accounts: "Ausgewählte Konten verknüpfen" + no_accounts_found: "Keine Konten gefunden. Bitte überprüfen Sie Ihre API-Token-Konfiguration." + no_api_token: "Mercury API-Token ist nicht konfiguriert. Bitte in den Einstellungen konfigurieren." + no_credentials_configured: "Bitte konfigurieren Sie zuerst Ihren Mercury API-Token in den Anbieter-Einstellungen." + no_name_placeholder: "(Kein Name)" + title: "Mercury-Konten auswählen" + select_existing_account: + account_already_linked: "Dieses Konto ist bereits mit einem Anbieter verknüpft" + all_accounts_already_linked: "Alle Mercury-Konten sind bereits verknüpft" + api_error: "API-Fehler: %{message}" + cancel: "Abbrechen" + configure_name_in_mercury: "Import nicht möglich – bitte Kontoname in Mercury konfigurieren" + description: "Wählen Sie ein Mercury-Konto zur Verknüpfung mit diesem Konto. Transaktionen werden automatisch synchronisiert und dedupliziert." + link_account: "Konto verknüpfen" + no_account_specified: "Kein Konto angegeben" + no_accounts_found: "Keine Mercury-Konten gefunden. Bitte überprüfen Sie Ihre API-Token-Konfiguration." + no_api_token: "Mercury API-Token ist nicht konfiguriert. Bitte in den Einstellungen konfigurieren." + no_credentials_configured: "Bitte konfigurieren Sie zuerst Ihren Mercury API-Token in den Anbieter-Einstellungen." + no_name_placeholder: "(Kein Name)" + title: "%{account_name} mit Mercury verknüpfen" + link_existing_account: + account_already_linked: "Dieses Konto ist bereits mit einem Anbieter verknüpft" + api_error: "API-Fehler: %{message}" + invalid_account_name: "Konto mit leerem Namen kann nicht verknüpft werden" + mercury_account_already_linked: "Dieses Mercury-Konto ist bereits mit einem anderen Konto verknüpft" + mercury_account_not_found: "Mercury-Konto nicht gefunden" + missing_parameters: "Erforderliche Parameter fehlen" + no_api_token: "Mercury API-Token nicht gefunden. Bitte in den Anbieter-Einstellungen konfigurieren." + success: "%{account_name} erfolgreich mit Mercury verknüpft" + setup_accounts: + account_type_label: "Kontotyp:" + all_accounts_linked: "Alle Ihre Mercury-Konten sind bereits eingerichtet." + api_error: "API-Fehler: %{message}" + fetch_failed: "Konten konnten nicht geladen werden" + no_accounts_to_setup: "Keine Konten zum Einrichten" + no_api_token: "Mercury API-Token ist nicht konfiguriert. Bitte überprüfen Sie Ihre Verbindungseinstellungen." + account_types: + skip: "Dieses Konto überspringen" + depository: "Giro- oder Sparkonto" + credit_card: "Kreditkarte" + investment: "Depot/Anlagekonto" + loan: "Darlehen oder Hypothek" + other_asset: "Sonstiges Vermögen" + subtype_labels: + depository: "Konto-Untertyp:" + credit_card: "" + investment: "Anlagetyp:" + loan: "Darlehenstyp:" + other_asset: "" + subtype_messages: + credit_card: "Kreditkarten werden automatisch als Kreditkartenkonten eingerichtet." + other_asset: "Für sonstiges Vermögen sind keine weiteren Optionen nötig." + subtypes: + depository: + checking: "Girokonto" + savings: "Sparkonto" + hsa: "Gesundheits-Sparkonto" + cd: "Festgeld" + money_market: "Geldmarkt" + investment: + brokerage: "Brokerage" + pension: "Rente" + retirement: "Altersvorsorge" + "401k": "401(k)" + roth_401k: "Roth 401(k)" + "403b": "403(b)" + tsp: "Thrift Savings Plan" + "529_plan": "529 Plan" + hsa: "Gesundheits-Sparkonto" + mutual_fund: "Investmentfonds" + ira: "Traditioneller IRA" + roth_ira: "Roth IRA" + angel: "Angel" + loan: + mortgage: "Hypothek" + student: "Studienkredit" + auto: "Autokredit" + other: "Sonstiges Darlehen" + balance: "Saldo" + cancel: "Abbrechen" + choose_account_type: "Wählen Sie den passenden Kontotyp für jedes Mercury-Konto:" + create_accounts: "Konten erstellen" + creating_accounts: "Konten werden erstellt..." + historical_data_range: "Zeitraum für Verlauf:" + subtitle: "Wählen Sie die passenden Kontotypen für Ihre importierten Konten" + sync_start_date_help: "Wählen Sie, wie weit die Transaktionshistorie synchronisiert werden soll. Maximal 3 Jahre Verlauf verfügbar." + sync_start_date_label: "Transaktionen synchronisieren ab:" + title: "Ihre Mercury-Konten einrichten" + complete_account_setup: + all_skipped: "Alle Konten wurden übersprungen. Es wurden keine Konten erstellt." + creation_failed: "Konten konnten nicht erstellt werden: %{error}" + no_accounts: "Keine Konten zum Einrichten." + success: "Erfolgreich %{count} Konto/Konten erstellt." + sync: + success: "Synchronisation gestartet" + update: + success: "Mercury-Verbindung aktualisiert" diff --git a/config/locales/views/mercury_items/es.yml b/config/locales/views/mercury_items/es.yml new file mode 100644 index 000000000..085607cd7 --- /dev/null +++ b/config/locales/views/mercury_items/es.yml @@ -0,0 +1,147 @@ +--- +es: + mercury_items: + create: + success: Conexión con Mercury creada con éxito + destroy: + success: Conexión con Mercury eliminada + index: + title: Conexiones de Mercury + loading: + loading_message: Cargando cuentas de Mercury... + loading_title: Cargando + link_accounts: + all_already_linked: + one: "La cuenta seleccionada (%{names}) ya está vinculada" + other: "Todas las %{count} cuentas seleccionadas ya están vinculadas: %{names}" + api_error: "Error de API: %{message}" + invalid_account_names: + one: "No se puede vincular una cuenta con el nombre en blanco" + other: "No se pueden vincular %{count} cuentas con nombres en blanco" + link_failed: Error al vincular cuentas + no_accounts_selected: Por favor, selecciona al menos una cuenta + no_api_token: No se encontró el token de API de Mercury. Por favor, configúralo en los Ajustes del Proveedor. + partial_invalid: "Se han vinculado %{created_count} cuenta(s) con éxito, %{already_linked_count} ya estaban vinculadas, %{invalid_count} cuenta(s) tenían nombres no válidos" + partial_success: "%{created_count} cuenta(s) vinculada(s) con éxito. %{already_linked_count} cuenta(s) ya estaban vinculadas: %{already_linked_names}" + success: + one: "%{count} cuenta vinculada con éxito" + other: "%{count} cuentas vinculadas con éxito" + mercury_item: + accounts_need_setup: Las cuentas necesitan configuración + delete: Eliminar conexión + deletion_in_progress: eliminación en curso... + error: Error + no_accounts_description: Esta conexión aún no tiene cuentas vinculadas. + no_accounts_title: Sin cuentas + setup_action: Configurar nuevas cuentas + setup_description: "%{linked} de %{total} cuentas vinculadas. Elige los tipos de cuenta para tus cuentas de Mercury recién importadas." + setup_needed: Nuevas cuentas listas para configurar + status: "Sincronizado hace %{timestamp}" + status_never: Nunca sincronizado + status_with_summary: "Sincronizado hace %{timestamp} - %{summary}" + syncing: Sincronizando... + total: Total + unlinked: Desvinculadas + select_accounts: + accounts_selected: cuentas seleccionadas + api_error: "Error de API: %{message}" + cancel: Cancelar + configure_name_in_mercury: "No se puede importar: por favor, configura el nombre de la cuenta en Mercury" + description: Selecciona las cuentas que deseas vincular a tu cuenta de %{product_name}. + link_accounts: Vincular cuentas seleccionadas + no_accounts_found: No se encontraron cuentas. Por favor, verifica la configuración de tu token de API. + no_api_token: El token de API de Mercury no está configurado. Por favor, configúralo en Ajustes. + no_credentials_configured: Por favor, configura primero tu token de API de Mercury en los Ajustes del Proveedor. + no_name_placeholder: "(Sin nombre)" + title: Seleccionar cuentas de Mercury + select_existing_account: + account_already_linked: Esta cuenta ya está vinculada a un proveedor + all_accounts_already_linked: Todas las cuentas de Mercury ya están vinculadas + api_error: "Error de API: %{message}" + cancel: Cancelar + configure_name_in_mercury: "No se puede importar: por favor, configura el nombre de la cuenta en Mercury" + description: Selecciona una cuenta de Mercury para vincular con esta cuenta. Las transacciones se sincronizarán y desduplicarán automáticamente. + link_account: Vincular cuenta + no_account_specified: No se especificó ninguna cuenta + no_accounts_found: No se encontraron cuentas de Mercury. Por favor, verifica la configuración de tu token de API. + no_api_token: El token de API de Mercury no está configurado. Por favor, configúralo en Ajustes. + no_credentials_configured: Por favor, configura primero tu token de API de Mercury en los Ajustes del Proveedor. + no_name_placeholder: "(Sin nombre)" + title: "Vincular %{account_name} con Mercury" + link_existing_account: + account_already_linked: Esta cuenta ya está vinculada a un proveedor + api_error: "Error de API: %{message}" + invalid_account_name: No se puede vincular una cuenta con el nombre en blanco + mercury_account_already_linked: Esta cuenta de Mercury ya está vinculada a otra cuenta + mercury_account_not_found: Cuenta de Mercury no encontrada + missing_parameters: Faltan parámetros requeridos + no_api_token: No se encontró el token de API de Mercury. Por favor, configúralo en los Ajustes del Proveedor. + success: "%{account_name} vinculada con Mercury con éxito" + setup_accounts: + account_type_label: "Tipo de cuenta:" + all_accounts_linked: "Todas tus cuentas de Mercury ya han sido configuradas." + api_error: "Error de API: %{message}" + fetch_failed: "Error al obtener las cuentas" + no_accounts_to_setup: "No hay cuentas para configurar" + no_api_token: "El token de API de Mercury no está configurado. Por favor, comprueba los ajustes de conexión." + account_types: + skip: Omitir esta cuenta + depository: Cuenta corriente o de ahorro + credit_card: Tarjeta de crédito + investment: Cuenta de inversión + loan: Préstamo o hipoteca + other_asset: Otro activo + subtype_labels: + depository: "Subtipo de cuenta:" + credit_card: "" + investment: "Tipo de inversión:" + loan: "Tipo de préstamo:" + other_asset: "" + subtype_messages: + credit_card: "Las tarjetas de crédito se configurarán automáticamente como cuentas de tarjeta de crédito." + other_asset: "No se necesitan opciones adicionales para otros activos." + subtypes: + depository: + checking: Corriente + savings: Ahorros + hsa: Cuenta de ahorros para la salud (HSA) + cd: Certificado de depósito + money_market: Mercado monetario + investment: + brokerage: Bróker + pension: Plan de pensiones + retirement: Jubilación + "401k": "401(k)" + roth_401k: "Roth 401(k)" + "403b": "403(b)" + tsp: Plan de ahorro TSP + "529_plan": Plan 529 + hsa: Cuenta de ahorros para la salud (HSA) + mutual_fund: Fondo de inversión + ira: IRA tradicional + roth_ira: Roth IRA + angel: Inversión Angel + loan: + mortgage: Hipoteca + student: Préstamo estudiantil + auto: Préstamo de coche + other: Otro préstamo + balance: Saldo + cancel: Cancelar + choose_account_type: "Elige el tipo de cuenta correcto para cada cuenta de Mercury:" + create_accounts: Crear cuentas + creating_accounts: Creando cuentas... + historical_data_range: "Rango de datos históricos:" + subtitle: Elige los tipos de cuenta correctos para tus cuentas importadas + sync_start_date_help: Selecciona cuánto tiempo atrás deseas sincronizar el historial de transacciones. Máximo 3 años de historial disponibles. + sync_start_date_label: "Empezar a sincronizar transacciones desde:" + title: Configura tus cuentas de Mercury + complete_account_setup: + all_skipped: "Se omitieron todas las cuentas. No se creó ninguna cuenta." + creation_failed: "Error al crear las cuentas: %{error}" + no_accounts: "No hay cuentas para configurar." + success: "Se han creado %{count} cuenta(s) con éxito." + sync: + success: Sincronización iniciada + update: + success: Conexión con Mercury actualizada \ No newline at end of file diff --git a/config/locales/views/mercury_items/pl.yml b/config/locales/views/mercury_items/pl.yml new file mode 100644 index 000000000..4fd424428 --- /dev/null +++ b/config/locales/views/mercury_items/pl.yml @@ -0,0 +1,157 @@ +--- +pl: + mercury_items: + create: + success: Połączenie Mercury zostało pomyślnie utworzone + destroy: + success: Połączenie Mercury zostało usunięte + index: + title: Połączenia Mercury + loading: + loading_message: Ładowanie kont Mercury... + loading_title: Ładowanie + link_accounts: + all_already_linked: + one: Wybrane konto (%{names}) jest już połączone + few: 'Wszystkie %{count} wybrane konta są już połączone: %{names}' + many: 'Wszystkie %{count} wybranych kont jest już połączonych: %{names}' + other: 'Wszystkie %{count} wybrane konta są już połączone: %{names}' + api_error: 'Błąd API: %{message}' + invalid_account_names: + one: Nie można połączyć konta bez nazwy + few: Nie można połączyć %{count} kont bez nazwy + many: Nie można połączyć %{count} kont bez nazwy + other: Nie można połączyć %{count} kont bez nazwy + link_failed: Nie udało się połączyć kont + no_accounts_selected: Wybierz co najmniej jedno konto + no_api_token: Nie znaleziono tokenu API Mercury. Skonfiguruj go w Ustawieniach dostawcy. + partial_invalid: Pomyślnie połączono %{created_count} konto(a), %{already_linked_count} było już połączonych, %{invalid_count} konto(a) miało nieprawidłowe nazwy + partial_success: 'Pomyślnie połączono %{created_count} konto(a). %{already_linked_count} konto(a) było już połączonych: %{already_linked_names}' + success: + one: Pomyślnie połączono %{count} konto + few: Pomyślnie połączono %{count} konta + many: Pomyślnie połączono %{count} kont + other: Pomyślnie połączono %{count} kont + mercury_item: + accounts_need_setup: Konta wymagają konfiguracji + delete: Usuń połączenie + deletion_in_progress: usuwanie w toku... + error: Błąd + no_accounts_description: To połączenie nie ma jeszcze żadnych połączonych kont. + no_accounts_title: Brak kont + setup_action: Skonfiguruj nowe konta + setup_description: "%{linked} z %{total} kont połączonych. Wybierz typy kont dla nowo zaimportowanych kont Mercury." + setup_needed: Nowe konta gotowe do konfiguracji + status: Zsynchronizowano %{timestamp} temu + status_never: Nigdy nie synchronizowano + status_with_summary: Ostatnia synchronizacja %{timestamp} temu - %{summary} + syncing: Synchronizacja... + total: Łącznie + unlinked: Niepodłączone + select_accounts: + accounts_selected: wybranych kont + api_error: 'Błąd API: %{message}' + cancel: Anuluj + configure_name_in_mercury: Nie można zaimportować — skonfiguruj nazwę konta w Mercury + description: Wybierz konta, które chcesz połączyć z kontem %{product_name}. + link_accounts: Połącz wybrane konta + no_accounts_found: Nie znaleziono kont. Sprawdź konfigurację tokenu API. + no_api_token: Token API Mercury nie jest skonfigurowany. Skonfiguruj go w Ustawieniach. + no_credentials_configured: Skonfiguruj token API Mercury najpierw w Ustawieniach dostawcy. + no_name_placeholder: "(Brak nazwy)" + title: Wybierz konta Mercury + select_existing_account: + account_already_linked: To konto jest już połączone z dostawcą + all_accounts_already_linked: Wszystkie konta Mercury są już połączone + api_error: 'Błąd API: %{message}' + cancel: Anuluj + configure_name_in_mercury: Nie można zaimportować — skonfiguruj nazwę konta w Mercury + description: Wybierz konto Mercury do połączenia z tym kontem. Transakcje będą synchronizowane i deduplikowane automatycznie. + link_account: Połącz konto + no_account_specified: Nie podano konta + no_accounts_found: Nie znaleziono kont Mercury. Sprawdź konfigurację tokenu API. + no_api_token: Token API Mercury nie jest skonfigurowany. Skonfiguruj go w Ustawieniach. + no_credentials_configured: Skonfiguruj token API Mercury najpierw w Ustawieniach dostawcy. + no_name_placeholder: "(Brak nazwy)" + title: Połącz %{account_name} z Mercury + link_existing_account: + account_already_linked: To konto jest już połączone z dostawcą + api_error: 'Błąd API: %{message}' + invalid_account_name: Nie można połączyć konta bez nazwy + mercury_account_already_linked: To konto Mercury jest już połączone z innym kontem + mercury_account_not_found: Nie znaleziono konta Mercury + missing_parameters: Brak wymaganych parametrów + no_api_token: Nie znaleziono tokenu API Mercury. Skonfiguruj go w Ustawieniach dostawcy. + success: Pomyślnie połączono %{account_name} z Mercury + setup_accounts: + account_type_label: 'Typ konta:' + all_accounts_linked: Wszystkie Twoje konta Mercury są już skonfigurowane. + api_error: 'Błąd API: %{message}' + fetch_failed: Nie udało się pobrać kont + no_accounts_to_setup: Brak kont do konfiguracji + no_api_token: Token API Mercury nie jest skonfigurowany. Sprawdź ustawienia połączenia. + account_types: + skip: Pomiń to konto + depository: Konto bieżące lub oszczędnościowe + credit_card: Karta kredytowa + investment: Konto inwestycyjne + loan: Pożyczka lub kredyt hipoteczny + other_asset: Inne aktywa + subtype_labels: + depository: 'Podtyp konta:' + credit_card: 'Podtyp karty kredytowej:' + investment: 'Typ inwestycji:' + loan: 'Typ pożyczki:' + other_asset: 'Podtyp aktywa:' + subtype_messages: + credit_card: Karty kredytowe zostaną automatycznie skonfigurowane jako konta kart kredytowych. + other_asset: Dla innych aktywów nie są potrzebne dodatkowe opcje. + subtypes: + depository: + checking: Konto bieżące + savings: Oszczędnościowe + hsa: Konto oszczędnościowe na cele zdrowotne + cd: Lokata terminowa + money_market: Rynek pieniężny + investment: + brokerage: Maklerskie + pension: Emerytura + retirement: Emerytalne + 401k: 401(k) + roth_401k: Roth 401(k) + 403b: 403(b) + tsp: Plan oszczędnościowy Thrift + 529_plan: 529 Plan + hsa: Konto oszczędnościowe na cele zdrowotne + mutual_fund: Fundusz inwestycyjny + ira: Tradycyjne IRA + roth_ira: Roth IRA + angel: Anielska + loan: + mortgage: Kredyt hipoteczny + student: Pożyczka studencka + auto: Pożyczka na samochód + other: Inna pożyczka + balance: Saldo + cancel: Anuluj + choose_account_type: 'Wybierz poprawny typ dla każdego konta Mercury:' + create_accounts: Utwórz konta + creating_accounts: Tworzenie kont... + historical_data_range: 'Zakres danych historycznych:' + subtitle: Wybierz poprawne typy dla importowanych kont + sync_start_date_help: Wybierz, jak daleko wstecz chcesz synchronizować historię transakcji. Dostępne są maksymalnie 3 lata historii. + sync_start_date_label: 'Synchronizuj transakcje od:' + title: Skonfiguruj swoje konta Mercury + complete_account_setup: + all_skipped: Wszystkie konta zostały pominięte. Nie utworzono żadnych kont. + creation_failed: 'Nie udało się utworzyć kont: %{error}' + no_accounts: Brak kont do skonfigurowania. + success: + one: Pomyślnie utworzono %{count} konto. + few: Pomyślnie utworzono %{count} konta. + many: Pomyślnie utworzono %{count} kont. + other: Pomyślnie utworzono %{count} konta. + sync: + success: Rozpoczęto synchronizację + update: + success: Połączenie Mercury zostało zaktualizowane diff --git a/config/locales/views/mfa/pl.yml b/config/locales/views/mfa/pl.yml new file mode 100644 index 000000000..4e2b6f868 --- /dev/null +++ b/config/locales/views/mfa/pl.yml @@ -0,0 +1,34 @@ +--- +pl: + mfa: + backup_codes: + backup_codes_description: Każdy kod można użyć tylko raz. Przechowuj je bezpiecznie. + backup_codes_title: Twoje kody zapasowe + continue: Przejdź do ustawień bezpieczeństwa + description: Przechowuj te kody zapasowe w bezpiecznym miejscu — będą potrzebne, jeśli utracisz dostęp do aplikacji uwierzytelniającej + page_title: Kody zapasowe + title: Zapisz swoje kody zapasowe + create: + invalid_code: Nieprawidłowy kod weryfikacyjny. Spróbuj ponownie. + disable: + success: Uwierzytelnianie dwuskładnikowe zostało wyłączone + new: + code_label: Kod weryfikacyjny + code_placeholder: Wpisz 6-cyfrowy kod + description: Zwiększ bezpieczeństwo konta, konfigurując uwierzytelnianie dwuskładnikowe + page_title: Konfiguracja uwierzytelniania dwuskładnikowego + scan_description: Użyj aplikacji uwierzytelniającej, np. Google Authenticator lub 1Password, aby zeskanować ten kod QR + scan_title: 1. Zeskanuj kod QR + secret_description: Jeśli nie możesz zeskanować kodu QR, wpisz ręcznie ten klucz tajny w aplikacji uwierzytelniającej + secret_title: Kod do ręcznego wpisania + title: Skonfiguruj uwierzytelnianie dwuskładnikowe + verify_button: Zweryfikuj i włącz 2FA + verify_description: Wpisz 6-cyfrowy kod z aplikacji uwierzytelniającej + verify_title: 2. Wprowadź kod weryfikacyjny + verify: + description: Wpisz kod z aplikacji uwierzytelniającej, aby kontynuować + page_title: Zweryfikuj uwierzytelnianie dwuskładnikowe + title: Uwierzytelnianie dwuskładnikowe + verify_button: Zweryfikuj + verify_code: + invalid_code: Nieprawidłowy kod uwierzytelniający. Spróbuj ponownie. diff --git a/config/locales/views/oidc_accounts/en.yml b/config/locales/views/oidc_accounts/en.yml index 4ed91677c..e04729307 100644 --- a/config/locales/views/oidc_accounts/en.yml +++ b/config/locales/views/oidc_accounts/en.yml @@ -18,6 +18,7 @@ en: info_email: "Email:" info_name: "Name:" submit_create: Create Account + submit_accept_invitation: Accept Invitation account_creation_disabled: New account creation via single sign-on is disabled. Please contact an administrator to create your account. cancel: Cancel new_user: diff --git a/config/locales/views/oidc_accounts/pl.yml b/config/locales/views/oidc_accounts/pl.yml new file mode 100644 index 000000000..8b24b8217 --- /dev/null +++ b/config/locales/views/oidc_accounts/pl.yml @@ -0,0 +1,34 @@ +--- +pl: + oidc_accounts: + link: + title_link: Połącz konto OIDC + title_create: Utwórz konto + verify_heading: Zweryfikuj swoją tożsamość + verify_description_html: "Aby połączyć konto %{provider}%{email_suffix}, zweryfikuj tożsamość, wpisując hasło." + email_suffix_html: " (adres: %{email})" + email_label: E-mail + email_placeholder: Wpisz e-mail + password_label: Hasło + password_placeholder: Wpisz hasło + verify_hint: To pomaga upewnić się, że tylko Ty możesz łączyć zewnętrzne konta ze swoim profilem. + submit_link: Połącz konto + create_heading: Utwórz nowe konto + create_description_html: "Nie znaleziono konta dla adresu %{email}. Kliknij poniżej, aby utworzyć nowe konto używając tożsamości %{provider}." + info_email: "E-mail:" + info_name: "Imię i nazwisko:" + submit_create: Utwórz konto + submit_accept_invitation: Zaakceptuj zaproszenie + account_creation_disabled: Tworzenie nowych kont przez logowanie jednokrotne jest wyłączone. Skontaktuj się z administratorem, aby utworzyć konto. + cancel: Anuluj + new_user: + title: Dokończ tworzenie konta + heading: Utwórz swoje konto + description: Potwierdź swoje dane, aby dokończyć tworzenie konta przy użyciu tożsamości %{provider}. + email_label: E-mail (od dostawcy SSO) + first_name_label: Imię + first_name_placeholder: Wpisz imię + last_name_label: Nazwisko + last_name_placeholder: Wpisz nazwisko + submit: Utwórz konto + cancel: Anuluj diff --git a/config/locales/views/onboardings/de.yml b/config/locales/views/onboardings/de.yml index 845e9a3fb..741805a63 100644 --- a/config/locales/views/onboardings/de.yml +++ b/config/locales/views/onboardings/de.yml @@ -16,8 +16,13 @@ de: first_name_placeholder: Vorname last_name: Nachname last_name_placeholder: Nachname + group_name: Gruppenname + group_name_placeholder: Gruppenname household_name: Haushaltsname household_name_placeholder: Haushaltsname + moniker_prompt: "%{product_name} wird genutzt mit …" + moniker_family: Familienmitglieder (nur Sie selbst oder mit Partner, Kindern usw.) + moniker_group: Personengruppe (Firma, Verein, Verband o. Ä.) country: Land submit: Weiter preferences: diff --git a/config/locales/views/onboardings/es.yml b/config/locales/views/onboardings/es.yml index 1fc48268a..d32b480cf 100644 --- a/config/locales/views/onboardings/es.yml +++ b/config/locales/views/onboardings/es.yml @@ -16,8 +16,13 @@ es: first_name_placeholder: Nombre last_name: Apellido last_name_placeholder: Apellido + group_name: Nombre del grupo + group_name_placeholder: Nombre del grupo household_name: Nombre del hogar household_name_placeholder: Nombre del hogar + moniker_prompt: "Usaré %{product_name} con..." + moniker_family: Miembros de la familia (solo tú o con pareja, adolescentes, etc.) + moniker_group: Un grupo de personas (empresa, club, asociación o cualquier otro tipo) country: País submit: Continuar preferences: diff --git a/config/locales/views/onboardings/pl.yml b/config/locales/views/onboardings/pl.yml new file mode 100644 index 000000000..f62cf790c --- /dev/null +++ b/config/locales/views/onboardings/pl.yml @@ -0,0 +1,66 @@ +--- +pl: + onboardings: + header: + sign_out: Wyloguj się + setup: Konfiguracja + preferences: Preferencje + goals: Cele + start: Początek + logout: + sign_out: Wyloguj się + show: + title: Skonfigurujmy Twoje konto + subtitle: Na początek skonfigurujmy Twój profil. + first_name: Imię + first_name_placeholder: Imię + last_name: Nazwisko + last_name_placeholder: Nazwisko + group_name: Nazwa grupy + group_name_placeholder: Nazwa grupy + household_name: Nazwa gospodarstwa domowego + household_name_placeholder: Nazwa gospodarstwa domowego + moniker_prompt: Będę używać %{product_name} z ... + moniker_family: Członkami rodziny (tylko sobą lub z partnerem, nastolatkami itd.) + moniker_group: Grupą osób (firma, klub, stowarzyszenie lub inny typ) + country: Kraj + submit: Dalej + preferences: + title: Skonfiguruj swoje preferencje + subtitle: Skonfigurujmy Twoje preferencje. + example: Przykładowe konto + preview: Podgląd sposobu wyświetlania danych na podstawie preferencji. + color_theme: Motyw kolorystyczny + theme_system: Systemowy + theme_light: Jasny + theme_dark: Ciemny + locale: Język + currency: Waluta + date_format: Format daty + submit: Zakończ + goals: + title: Co Cię tutaj sprowadza? + subtitle: Wybierz jeden lub więcej celów, które chcesz osiągnąć, używając %{product_name} do zarządzania finansami osobistymi. + unified_accounts: Widzieć wszystkie moje konta w jednym miejscu + cashflow: Lepiej rozumieć przepływy pieniężne i wydatki + budgeting: Zarządzać planami finansowymi i budżetem + partner: Zarządzać finansami z partnerem + investments: Śledzić inwestycje + ai_insights: Korzystać z AI do lepszego zrozumienia moich finansów + optimization: Analizować i optymalizować konta + reduce_stress: Zmniejszyć stres lub niepokój związany z finansami + submit: Dalej + trial: + title: Wypróbuj Sure przez 45 dni + data_deletion: Dane zostaną wtedy usunięte + description_html: Od dziś możesz dokładnie przetestować produkt.
    Jeśli Ci się spodoba, uruchom go samodzielnie lub wesprzyj projekt, aby dalej korzystać tutaj. + try_button: Wypróbuj Sure przez 45 dni + continue_trial: Kontynuuj okres próbny + upgrade: Ulepsz + how_it_works: Jak to tutaj działa + today: Dziś + today_description: Otrzymasz bezpłatny dostęp do Sure na 45 dni na naszym AWS. + in_40_days: Za 40 dni (%{date}) + in_40_days_description: Wyślemy Ci przypomnienie o eksporcie danych. + in_45_days: Za 45 dni (%{date}) + in_45_days_description: Usuwamy dane — wesprzyj projekt, aby dalej korzystać z Sure tutaj! diff --git a/config/locales/views/other_assets/de.yml b/config/locales/views/other_assets/de.yml index 969e44f52..a6bc88674 100644 --- a/config/locales/views/other_assets/de.yml +++ b/config/locales/views/other_assets/de.yml @@ -3,5 +3,7 @@ de: other_assets: edit: edit: "%{account} bearbeiten" + balance_tracking_info: "Sonstige Vermögenswerte werden über manuelle Bewertungen („Neuer Saldo“) erfasst, nicht über Buchungen. Cashflow ändert den Kontostand nicht." new: title: Vermögenswertdetails eingeben + balance_tracking_info: "Sonstige Vermögenswerte werden über manuelle Bewertungen („Neuer Saldo“) erfasst, nicht über Buchungen. Cashflow ändert den Kontostand nicht." diff --git a/config/locales/views/other_assets/es.yml b/config/locales/views/other_assets/es.yml index d04b956ae..3a48fddcd 100644 --- a/config/locales/views/other_assets/es.yml +++ b/config/locales/views/other_assets/es.yml @@ -3,5 +3,7 @@ es: other_assets: edit: edit: Editar %{account} + balance_tracking_info: "El seguimiento de otros activos se realiza mediante valoraciones manuales usando 'Nuevo saldo', no mediante transacciones. El flujo de caja no afectará al saldo de la cuenta." new: title: Introduce los detalles del activo + balance_tracking_info: "El seguimiento de otros activos se realiza mediante valoraciones manuales usando 'Nuevo saldo', no mediante transacciones. El flujo de caja no afectará al saldo de la cuenta." diff --git a/config/locales/views/other_assets/pl.yml b/config/locales/views/other_assets/pl.yml new file mode 100644 index 000000000..3e27ea98e --- /dev/null +++ b/config/locales/views/other_assets/pl.yml @@ -0,0 +1,9 @@ +--- +pl: + other_assets: + edit: + edit: Edytuj %{account} + balance_tracking_info: Inne aktywa są śledzone poprzez ręczne wyceny za pomocą opcji „Nowe saldo”, a nie transakcje. Przepływy pieniężne nie wpływają na saldo konta. + new: + title: Wprowadź dane aktywa + balance_tracking_info: Inne aktywa są śledzone poprzez ręczne wyceny za pomocą opcji „Nowe saldo”, a nie transakcje. Przepływy pieniężne nie wpływają na saldo konta. diff --git a/config/locales/views/other_liabilities/pl.yml b/config/locales/views/other_liabilities/pl.yml new file mode 100644 index 000000000..b6ef16a55 --- /dev/null +++ b/config/locales/views/other_liabilities/pl.yml @@ -0,0 +1,7 @@ +--- +pl: + other_liabilities: + edit: + edit: Edytuj %{account} + new: + title: Wprowadź dane zobowiązania diff --git a/config/locales/views/pages/de.yml b/config/locales/views/pages/de.yml index 4df171506..a381eb2d6 100644 --- a/config/locales/views/pages/de.yml +++ b/config/locales/views/pages/de.yml @@ -3,10 +3,20 @@ de: pages: changelog: title: Was ist neu + privacy: + title: Datenschutzrichtlinie + heading: Datenschutzrichtlinie + placeholder: Der Inhalt der Datenschutzrichtlinie wird hier angezeigt. + terms: + title: Nutzungsbedingungen + heading: Nutzungsbedingungen + placeholder: Der Inhalt der Nutzungsbedingungen wird hier angezeigt. dashboard: welcome: "Willkommen zurück, %{name}" subtitle: "Hier siehst du, was in deinen Finanzen passiert." new: "Neu" + drag_to_reorder: "Bereich per Drag & Drop neu anordnen" + toggle_section: "Sichtbarkeit des Bereichs umschalten" net_worth_chart: data_not_available: Für den ausgewählten Zeitraum sind keine Daten verfügbar. title: Nettovermögen @@ -15,6 +25,7 @@ de: no_account_subtitle: Da noch keine Konten hinzugefügt wurden, gibt es keine Daten anzuzeigen. Füge dein erstes Konto hinzu, um Dashboard-Daten zu sehen. no_account_title: Noch keine Konten vorhanden balance_sheet: + title: "Bilanz" no_items: "Noch keine %{name}" add_accounts: "Füge deine %{name}-Konten hinzu, um eine vollständige Übersicht zu erhalten." cashflow_sankey: @@ -29,3 +40,19 @@ de: outflows_donut: title: "Ausgaben" total_outflows: "Gesamtausgaben" + categories: "Kategorien" + value: "Wert" + weight: "Gewicht" + investment_summary: + title: "Investitionen" + total_return: "Gesamtrendite" + holding: "Position" + weight: "Gewicht" + value: "Wert" + return: "Rendite" + period_activity: "%{period} Aktivität" + contributions: "Einlagen" + withdrawals: "Entnahmen" + trades: "Trades" + no_investments: "Keine Anlagekonten" + add_investment: "Füge ein Anlagekonto hinzu, um dein Portfolio zu verfolgen" diff --git a/config/locales/views/pages/es.yml b/config/locales/views/pages/es.yml index 1c3c86115..db7095fb2 100644 --- a/config/locales/views/pages/es.yml +++ b/config/locales/views/pages/es.yml @@ -3,10 +3,20 @@ es: pages: changelog: title: Novedades + privacy: + title: Política de privacidad + heading: Política de privacidad + placeholder: El contenido de la política de privacidad se mostrará aquí. + terms: + title: Condiciones del servicio + heading: Condiciones del servicio + placeholder: El contenido de las condiciones del servicio se mostrará aquí. dashboard: welcome: "Bienvenido de nuevo, %{name}" subtitle: "Esto es lo que está pasando con tus finanzas" new: "Nuevo" + drag_to_reorder: "Arrastra para reordenar la sección" + toggle_section: "Alternar visibilidad de la sección" net_worth_chart: data_not_available: Datos no disponibles para el período seleccionado title: Patrimonio neto @@ -15,6 +25,7 @@ es: no_account_subtitle: Como no se han añadido cuentas, no hay datos para mostrar. Añade tus primeras cuentas para empezar a ver los datos del panel. no_account_title: Aún no hay cuentas balance_sheet: + title: "Balance de situación" no_items: "Aún no hay %{name}" add_accounts: "Añade tus cuentas de %{name} para ver un desglose completo" cashflow_sankey: @@ -29,3 +40,19 @@ es: outflows_donut: title: "Salidas" total_outflows: "Salidas totales" + categories: "Categorías" + value: "Valor" + weight: "Peso" + investment_summary: + title: "Inversiones" + total_return: "Rentabilidad total" + holding: "Activo" + weight: "Peso" + value: "Valor" + return: "Rentabilidad" + period_activity: "Actividad de %{period}" + contributions: "Aportaciones" + withdrawals: "Retiradas" + trades: "Operaciones" + no_investments: "No hay cuentas de inversión" + add_investment: "Añade una cuenta de inversión para realizar el seguimiento de tu cartera" \ No newline at end of file diff --git a/config/locales/views/pages/pl.yml b/config/locales/views/pages/pl.yml new file mode 100644 index 000000000..bdf2c9713 --- /dev/null +++ b/config/locales/views/pages/pl.yml @@ -0,0 +1,79 @@ +--- +pl: + pages: + changelog: + title: Co nowego + privacy: + title: Polityka prywatności + heading: Polityka prywatności + placeholder: Treść polityki prywatności będzie wyświetlana tutaj. + terms: + title: Warunki korzystania z usługi + heading: Warunki korzystania z usługi + placeholder: Treść warunków korzystania z usługi będzie wyświetlana tutaj. + dashboard: + welcome: Witaj ponownie, %{name} + subtitle: Zobacz, co dzieje się z Twoimi finansami + new: Nowe + bond_rate_review_notice: + one: "1 partia obligacji wymaga aktualizacji stopy emisji (%{accounts})." + few: "%{count} partie obligacji wymagają aktualizacji stopy emisji (%{accounts})." + many: "%{count} partii obligacji wymaga aktualizacji stopy emisji (%{accounts})." + other: "%{count} partii obligacji wymaga aktualizacji stopy emisji (%{accounts})." + drag_to_reorder: Przeciągnij, aby zmienić kolejność sekcji + toggle_section: Przełącz widoczność sekcji + net_worth_chart: + data_not_available: Dane niedostępne dla wybranego okresu + title: Majątek netto + no_account_empty_state: + new_account: Nowe konto + no_account_subtitle: Ponieważ nie dodano jeszcze żadnych kont, nie ma danych do wyświetlenia. Dodaj pierwsze konto, aby zacząć przeglądać dane pulpitu. + no_account_title: Brak kont + balance_sheet: + title: Bilans + no_items: Brak %{name} + add_accounts: Dodaj konta %{name}, aby zobaczyć pełny podział + cashflow_sankey: + title: Przepływy pieniężne + no_data_title: Brak danych o przepływach pieniężnych dla tego okresu + no_data_description: Dodaj transakcje, aby wyświetlić dane o przepływach pieniężnych, albo poszerz zakres czasu + add_transaction: Dodaj transakcję + no_accounts: + title: Brak kont + description: Dodaj konta, aby wyświetlić dane o majątku netto + add_account: Dodaj konto + outflows_donut: + title: Wydatki + total_outflows: Łączne wydatki + categories: Kategorie + value: Wartość + weight: Udział + investment_summary: + title: Inwestycje + total_return: Łączny zwrot + holding: Pozycja + weight: Udział + value: Wartość + return: Zwrot + period_activity: Aktywność za %{period} + contributions: Wpłaty + withdrawals: Wypłaty + trades: Transakcje giełdowe + no_investments: Brak kont inwestycyjnych + add_investment: Dodaj konto inwestycyjne, aby śledzić swój portfel + bond_summary: + title: Obligacje + total_return: Łączny zwrot + bond: Obligacja + rate: Oprocentowanie + principal: Kapitał + maturity: Zapadalność + maturity_label: Data zapadalności + principal_term: Kapitał, %{term} + term_months: + one: 1 miesiąc + few: "%{count} miesiące" + many: "%{count} miesięcy" + other: "%{count} miesiąca" + no_bonds: Brak kont obligacji + account_wrapper: "%{account} • %{wrapper}" diff --git a/config/locales/views/password_mailer/pl.yml b/config/locales/views/password_mailer/pl.yml new file mode 100644 index 000000000..f9144cf1f --- /dev/null +++ b/config/locales/views/password_mailer/pl.yml @@ -0,0 +1,8 @@ +--- +pl: + password_mailer: + password_reset: + cta: Zresetuj hasło + ignore_if_not_requested: Jeśli to nie Ty wysłałeś(aś) to żądanie, zignoruj tę wiadomość. + request_made: Otrzymaliśmy prośbę o reset hasła do %{product_name}. Kliknij link, aby je zresetować. + subject: "%{product_name}: zresetuj hasło" diff --git a/config/locales/views/password_resets/de.yml b/config/locales/views/password_resets/de.yml index 01d03288d..4f7536949 100644 --- a/config/locales/views/password_resets/de.yml +++ b/config/locales/views/password_resets/de.yml @@ -1,6 +1,8 @@ --- de: password_resets: + disabled: Passwort-Zurücksetzen über Sure ist deaktiviert. Bitte setzen Sie Ihr Passwort über Ihren Identitätsanbieter zurück. + sso_only_user: Ihr Konto nutzt SSO zur Anmeldung. Bitte wenden Sie sich an Ihren Administrator, um Ihre Zugangsdaten zu verwalten. edit: title: Passwort zurücksetzen new: diff --git a/config/locales/views/password_resets/es.yml b/config/locales/views/password_resets/es.yml index 36fe99a79..4fa5c10ea 100644 --- a/config/locales/views/password_resets/es.yml +++ b/config/locales/views/password_resets/es.yml @@ -1,14 +1,15 @@ --- es: password_resets: + disabled: El restablecimiento de contraseña a través de Sure está desactivado. Por favor, restablece tu contraseña a través de tu proveedor de identidad. + sso_only_user: Tu cuenta utiliza SSO para la autenticación. Por favor, contacta con tu administrador para gestionar tus credenciales. edit: title: Restablecer contraseña new: - requested: Por favor, revisa tu correo electrónico para obtener - un enlace para restablecer tu contraseña. + requested: Por favor, revisa tu correo electrónico para obtener un enlace para restablecer tu contraseña. submit: Restablecer contraseña title: Restablecer contraseña back: Volver update: - invalid_token: Token inválido. - success: Tu contraseña ha sido restablecida. + invalid_token: Token no válido. + success: Tu contraseña ha sido restablecida. \ No newline at end of file diff --git a/config/locales/views/password_resets/pl.yml b/config/locales/views/password_resets/pl.yml new file mode 100644 index 000000000..6ee9f6368 --- /dev/null +++ b/config/locales/views/password_resets/pl.yml @@ -0,0 +1,15 @@ +--- +pl: + password_resets: + disabled: Resetowanie hasła przez Sure jest wyłączone. Zresetuj hasło przez swojego dostawcę tożsamości. + sso_only_user: Twoje konto używa SSO do uwierzytelniania. Skontaktuj się z administratorem, aby zarządzać danymi logowania. + edit: + title: Zresetuj hasło + new: + requested: Sprawdź swoją skrzynkę e-mail, aby zresetować hasło. + submit: Zresetuj hasło + title: Zresetuj hasło + back: Wróć + update: + invalid_token: Nieprawidłowy token. + success: Twoje hasło zostało zresetowane. diff --git a/config/locales/views/passwords/pl.yml b/config/locales/views/passwords/pl.yml new file mode 100644 index 000000000..19c1cef79 --- /dev/null +++ b/config/locales/views/passwords/pl.yml @@ -0,0 +1,10 @@ +--- +pl: + passwords: + edit: + password: Nowe hasło + password_challenge: Obecne hasło + submit: Zresetuj hasło + title: Zaktualizuj hasło + update: + success: Twoje hasło zostało zresetowane. diff --git a/config/locales/views/pdf_import_mailer/de.yml b/config/locales/views/pdf_import_mailer/de.yml new file mode 100644 index 000000000..8df811a38 --- /dev/null +++ b/config/locales/views/pdf_import_mailer/de.yml @@ -0,0 +1,17 @@ +--- +de: + pdf_import_mailer: + next_steps: + greeting: "Hallo %{name}," + intro: "Wir haben die PDF-Datei analysiert, die Sie an %{product} hochgeladen haben." + document_type_label: Dokumenttyp + summary_label: KI-Zusammenfassung + transactions_note: Dieses Dokument scheint Buchungen zu enthalten. Sie können diese jetzt extrahieren und prüfen. + document_stored_note: Dieses Dokument wurde zu Ihrer Referenz gespeichert. Es kann für Kontext in zukünftigen KI-Gesprächen verwendet werden. + next_steps_label: Was als Nächstes? + next_steps_intro: "Sie haben mehrere Möglichkeiten:" + option_extract_transactions: Buchungen aus diesem Kontoauszug extrahieren + option_keep_reference: Dokument für Referenz in zukünftigen KI-Gesprächen behalten + option_delete: Import löschen, wenn Sie ihn nicht mehr benötigen + view_import_button: Importdetails anzeigen + footer_note: Dies ist eine automatische Nachricht. Bitte antworten Sie nicht direkt auf diese E-Mail. diff --git a/config/locales/views/pdf_import_mailer/es.yml b/config/locales/views/pdf_import_mailer/es.yml new file mode 100644 index 000000000..d7c2e24af --- /dev/null +++ b/config/locales/views/pdf_import_mailer/es.yml @@ -0,0 +1,17 @@ +--- +es: + pdf_import_mailer: + next_steps: + greeting: "Hola %{name}," + intro: "Hemos terminado de analizar el documento PDF que subiste a %{product}." + document_type_label: Tipo de documento + summary_label: Resumen de la IA + transactions_note: Este documento parece contener transacciones. Ya puedes extraerlas y revisarlas. + document_stored_note: Este documento ha sido guardado para tu referencia. Se puede utilizar para proporcionar contexto en futuras conversaciones con la IA. + next_steps_label: ¿Qué sigue ahora? + next_steps_intro: "Tienes varias opciones:" + option_extract_transactions: Extraer las transacciones de este extracto + option_keep_reference: Guardar este documento como referencia para futuras conversaciones con la IA + option_delete: Eliminar esta importación si ya no la necesitas + view_import_button: Ver detalles de la importación + footer_note: Este es un mensaje automático. Por favor, no respondas directamente a este correo electrónico. \ No newline at end of file diff --git a/config/locales/views/pdf_import_mailer/pl.yml b/config/locales/views/pdf_import_mailer/pl.yml new file mode 100644 index 000000000..7881fcf32 --- /dev/null +++ b/config/locales/views/pdf_import_mailer/pl.yml @@ -0,0 +1,17 @@ +--- +pl: + pdf_import_mailer: + next_steps: + greeting: "Cześć %{name}," + intro: "Zakończyliśmy analizę dokumentu PDF przesłanego do %{product}." + document_type_label: Typ dokumentu + summary_label: Podsumowanie AI + transactions_note: Ten dokument wygląda na zawierający transakcje. Możesz je teraz wyodrębnić i przejrzeć. + document_stored_note: Ten dokument został zapisany do Twojego wglądu. Może służyć jako kontekst w przyszłych rozmowach z AI. + next_steps_label: Co dalej? + next_steps_intro: "Masz kilka opcji:" + option_extract_transactions: Wyodrębnij transakcje z tego wyciągu + option_keep_reference: Zachowaj dokument jako materiał referencyjny do przyszłych rozmów z AI + option_delete: Usuń ten import, jeśli nie jest już potrzebny + view_import_button: Zobacz szczegóły importu + footer_note: To wiadomość automatyczna. Prosimy nie odpowiadać bezpośrednio na ten e-mail. diff --git a/config/locales/views/pending_duplicate_merges/en.yml b/config/locales/views/pending_duplicate_merges/en.yml new file mode 100644 index 000000000..c62c6e2b0 --- /dev/null +++ b/config/locales/views/pending_duplicate_merges/en.yml @@ -0,0 +1,14 @@ +--- +en: + pending_duplicate_merges: + new: + title: Merge with Posted Transaction + warning_title: Manual Duplicate Merging + warning_description: Use this to manually merge a pending transaction with its posted version. This will delete the pending transaction and keep only the posted one. + pending_transaction: Pending Transaction + select_posted: Select Posted Transaction to Merge With + showing_range: "Showing %{start} - %{end}" + previous: "← Previous 10" + next: "Next 10 →" + no_candidates: No posted transactions found in this account. + submit_button: Merge Transactions diff --git a/config/locales/views/pending_duplicate_merges/pl.yml b/config/locales/views/pending_duplicate_merges/pl.yml new file mode 100644 index 000000000..17051288b --- /dev/null +++ b/config/locales/views/pending_duplicate_merges/pl.yml @@ -0,0 +1,14 @@ +--- +pl: + pending_duplicate_merges: + new: + title: Połącz z zaksięgowaną transakcją + warning_title: Ręczne łączenie duplikatów + warning_description: Użyj tej opcji, aby ręcznie połączyć transakcję oczekującą z jej zaksięgowaną wersją. Transakcja oczekująca zostanie usunięta, a pozostanie tylko zaksięgowana. + pending_transaction: Transakcja oczekująca + select_posted: Wybierz zaksięgowaną transakcję do połączenia + showing_range: "Wyświetlane %{start} - %{end}" + previous: "← Poprzednie 10" + next: "Następne 10 →" + no_candidates: Nie znaleziono zaksięgowanych transakcji na tym koncie. + submit_button: Połącz transakcje diff --git a/config/locales/views/plaid_items/de.yml b/config/locales/views/plaid_items/de.yml index 01371319d..033449fd2 100644 --- a/config/locales/views/plaid_items/de.yml +++ b/config/locales/views/plaid_items/de.yml @@ -21,3 +21,8 @@ de: status_never: Synchronisierung erforderlich syncing: Wird synchronisiert... update: Verbindung aktualisieren + select_existing_account: + title: "%{account_name} mit Plaid verknüpfen" + description: Wählen Sie ein Plaid-Konto zur Verknüpfung mit Ihrem bestehenden Konto + cancel: Abbrechen + link_account: Konto verknüpfen diff --git a/config/locales/views/plaid_items/es.yml b/config/locales/views/plaid_items/es.yml index 7931f1591..32c3802fe 100644 --- a/config/locales/views/plaid_items/es.yml +++ b/config/locales/views/plaid_items/es.yml @@ -8,12 +8,10 @@ es: plaid_item: add_new: Añadir nueva conexión confirm_accept: Eliminar institución - confirm_body: Esto eliminará permanentemente todas las cuentas de este grupo y - todos los datos asociados. + confirm_body: Esto eliminará permanentemente todas las cuentas de este grupo y todos los datos asociados. confirm_title: ¿Eliminar institución? connection_lost: Conexión perdida - connection_lost_description: Esta conexión ya no es válida. Necesitarás - eliminar esta conexión y añadirla de nuevo para continuar sincronizando los datos. + connection_lost_description: Esta conexión ya no es válida. Necesitarás eliminar esta conexión y añadirla de nuevo para continuar sincronizando los datos. delete: Eliminar error: Ocurrió un error mientras se sincronizaban los datos no_accounts_description: No pudimos cargar ninguna cuenta de esta institución financiera. @@ -23,3 +21,8 @@ es: status_never: Requiere sincronización de datos syncing: Sincronizando... update: Actualizar conexión + select_existing_account: + title: "Vincular %{account_name} a Plaid" + description: Selecciona una cuenta de Plaid para vincularla a tu cuenta existente + cancel: Cancelar + link_account: Vincular cuenta \ No newline at end of file diff --git a/config/locales/views/plaid_items/pl.yml b/config/locales/views/plaid_items/pl.yml new file mode 100644 index 000000000..d72409f10 --- /dev/null +++ b/config/locales/views/plaid_items/pl.yml @@ -0,0 +1,28 @@ +--- +pl: + plaid_items: + create: + success: Konto zostało pomyślnie połączone. Poczekaj na synchronizację kont. + destroy: + success: Konta zostały zaplanowane do usunięcia. + plaid_item: + add_new: Dodaj nowe połączenie + confirm_accept: Usuń instytucję + confirm_body: To trwale usunie wszystkie konta w tej grupie oraz wszystkie powiązane dane. + confirm_title: Usunąć instytucję? + connection_lost: Utracono połączenie + connection_lost_description: To połączenie nie jest już prawidłowe. Aby kontynuować synchronizację danych, usuń je i dodaj ponownie. + delete: Usuń + error: Wystąpił błąd podczas synchronizacji danych + no_accounts_description: Nie udało się wczytać żadnych kont z tej instytucji finansowej. + no_accounts_title: Nie znaleziono kont + requires_update: Połącz ponownie + status: Ostatnia synchronizacja %{timestamp} temu + status_never: Wymaga synchronizacji danych + syncing: Synchronizacja... + update: Aktualizuj + select_existing_account: + title: Połącz %{account_name} z Plaid + description: Wybierz konto Plaid, aby połączyć je z istniejącym kontem + cancel: Anuluj + link_account: Połącz konto diff --git a/config/locales/views/properties/pl.yml b/config/locales/views/properties/pl.yml new file mode 100644 index 000000000..efa8e4528 --- /dev/null +++ b/config/locales/views/properties/pl.yml @@ -0,0 +1,32 @@ +--- +pl: + properties: + edit: + edit: Edytuj %{account} + form: + address_line1: Adres + address_line1_placeholder: 123 Main St + area: Powierzchnia użytkowa + area_placeholder: '2000' + area_unit: Jednostka miary + country: Kraj + country_placeholder: US + locality: Miasto + locality_placeholder: San Francisco + none: Brak + postal_code: Kod pocztowy + postal_code_placeholder: '94105' + region: Województwo/Region + region_placeholder: CA + subtype_prompt: Wybierz typ nieruchomości + year_built: Rok budowy + year_built_placeholder: '2000' + new: + title: Wprowadź dane nieruchomości + overview: + living_area: Powierzchnia użytkowa + market_value: Wartość rynkowa + purchase_price: Cena zakupu + trend: Trend + unknown: Nieznane + year_built: Rok budowy diff --git a/config/locales/views/recurring_transactions/de.yml b/config/locales/views/recurring_transactions/de.yml index 305e2461f..ddb4bc938 100644 --- a/config/locales/views/recurring_transactions/de.yml +++ b/config/locales/views/recurring_transactions/de.yml @@ -4,10 +4,18 @@ de: upcoming: Anstehende wiederkehrende Transaktionen projected: Prognostiziert recurring: Wiederkehrend + expected_today: "Erwartet heute" + expected_in: + one: "Erwartet in %{count} Tag" + other: "Erwartet in %{count} Tagen" expected_on: Erwartet am %{date} day_of_month: Tag %{day} des Monats identify_patterns: Muster erkennen cleanup_stale: Alte Einträge bereinigen + settings: + enable_label: Wiederkehrende Transaktionen aktivieren + enable_description: Erkenne automatisch wiederkehrende Transaktionsmuster und zeige anstehende prognostizierte Transaktionen an. + settings_updated: Einstellungen für wiederkehrende Transaktionen aktualisiert info: title: Automatische Mustererkennung manual_description: Du kannst Muster manuell erkennen oder alte wiederkehrende Transaktionen mit den obigen Schaltflächen bereinigen. @@ -21,6 +29,11 @@ de: marked_active: Wiederkehrende Transaktion als aktiv markiert deleted: Wiederkehrende Transaktion gelöscht confirm_delete: Bist du sicher, dass du diese wiederkehrende Transaktion löschen möchtest? + marked_as_recurring: Transaktion als wiederkehrend markiert + already_exists: Für dieses Muster existiert bereits eine manuelle wiederkehrende Transaktion + creation_failed: Wiederkehrende Transaktion konnte nicht erstellt werden. Bitte überprüfe die Transaktionsdetails und versuche es erneut. + unexpected_error: Beim Erstellen der wiederkehrenden Transaktion ist ein unerwarteter Fehler aufgetreten + amount_range: "Bereich: %{min} bis %{max}" empty: title: Keine wiederkehrenden Transaktionen gefunden description: Klicke auf „Muster erkennen“, um automatisch wiederkehrende Transaktionen aus deinem Verlauf zu erkennen. @@ -35,3 +48,5 @@ de: status: active: Aktiv inactive: Inaktiv + badges: + manual: Manuell diff --git a/config/locales/views/recurring_transactions/es.yml b/config/locales/views/recurring_transactions/es.yml index bd42f4268..690b835d8 100644 --- a/config/locales/views/recurring_transactions/es.yml +++ b/config/locales/views/recurring_transactions/es.yml @@ -5,10 +5,18 @@ es: upcoming: Próximas Transacciones Recurrentes projected: Proyectado recurring: Recurrente + expected_today: "Esperado hoy" + expected_in: + one: "Esperado en %{count} día" + other: "Esperado en %{count} días" expected_on: Esperado el %{date} day_of_month: Día %{day} del mes identify_patterns: Identificar Patrones cleanup_stale: Limpiar Obsoletos + settings: + enable_label: Activar transacciones recurrentes + enable_description: Detecta automáticamente patrones de transacciones recurrentes y muestra las próximas transacciones proyectadas. + settings_updated: Configuración de transacciones recurrentes actualizada info: title: Detección Automática de Patrones manual_description: Puedes identificar patrones manualmente o limpiar transacciones recurrentes obsoletas usando los botones de arriba. @@ -22,11 +30,16 @@ es: marked_active: Transacción recurrente marcada como activa deleted: Transacción recurrente eliminada confirm_delete: ¿Estás seguro de que deseas eliminar esta transacción recurrente? + marked_as_recurring: Transacción marcada como recurrente + already_exists: Ya existe una transacción recurrente manual para este patrón + creation_failed: Error al crear la transacción recurrente. Por favor, comprueba los detalles e inténtalo de nuevo. + unexpected_error: Ha ocurrido un error inesperado al crear la transacción recurrente + amount_range: "Rango: %{min} a %{max}" empty: title: No se encontraron transacciones recurrentes description: Haz clic en "Identificar Patrones" para detectar automáticamente transacciones recurrentes de tu historial de transacciones. table: - merchant: Comerciante + merchant: Nombre amount: Importe expected_day: Día Esperado next_date: Próxima Fecha @@ -36,3 +49,5 @@ es: status: active: Activa inactive: Inactiva + badges: + manual: Manual \ No newline at end of file diff --git a/config/locales/views/recurring_transactions/pl.yml b/config/locales/views/recurring_transactions/pl.yml new file mode 100644 index 000000000..3825ffc41 --- /dev/null +++ b/config/locales/views/recurring_transactions/pl.yml @@ -0,0 +1,54 @@ +--- +pl: + recurring_transactions: + title: Transakcje cykliczne + upcoming: Nadchodzące transakcje cykliczne + projected: Prognozowane + recurring: Cykliczne + expected_today: Oczekiwane dziś + expected_in: + one: Oczekiwane za %{count} dzień + few: Oczekiwane za %{count} dni + many: Oczekiwane za %{count} dni + other: Oczekiwane za %{count} dni + day_of_month: Dzień %{day} miesiąca + identify_patterns: Wykryj wzorce + cleanup_stale: Wyczyść nieaktualne + settings: + enable_label: Włącz transakcje cykliczne + enable_description: Automatycznie wykrywaj wzorce transakcji cyklicznych i pokazuj nadchodzące prognozowane transakcje. + settings_updated: Ustawienia transakcji cyklicznych zostały zaktualizowane + info: + title: Automatyczne wykrywanie wzorców + manual_description: Możesz ręcznie wykryć wzorce lub wyczyścić nieaktualne transakcje cykliczne przy użyciu przycisków powyżej. + automatic_description: 'Automatyczne wykrywanie uruchamia się także po:' + triggers: + - zakończeniu importu CSV (transakcje, transakcje giełdowe, konta itd.) + - zakończeniu synchronizacji dowolnego dostawcy (Plaid, SimpleFIN itd.) + identified: Wykryto %{count} wzorców transakcji cyklicznych + cleaned_up: Wyczyszczono %{count} nieaktualnych transakcji cyklicznych + marked_inactive: Transakcję cykliczną oznaczono jako nieaktywną + marked_active: Transakcję cykliczną oznaczono jako aktywną + deleted: Transakcja cykliczna została usunięta + confirm_delete: Czy na pewno chcesz usunąć tę transakcję cykliczną? + marked_as_recurring: Transakcja została oznaczona jako cykliczna + already_exists: Dla tego wzorca istnieje już ręcznie utworzona transakcja cykliczna + creation_failed: Nie udało się utworzyć transakcji cyklicznej. Sprawdź szczegóły transakcji i spróbuj ponownie. + unexpected_error: Wystąpił nieoczekiwany błąd podczas tworzenia transakcji cyklicznej + amount_range: 'Zakres: %{min} do %{max}' + empty: + title: Nie znaleziono transakcji cyklicznych + description: Kliknij „Wykryj wzorce”, aby automatycznie wykryć transakcje cykliczne na podstawie historii transakcji. + table: + merchant: Nazwa + amount: Kwota + expected_day: Oczekiwany dzień + next_date: Następna data + last_occurrence: Ostatnie wystąpienie + status: Status + actions: Akcje + status: + active: Aktywna + inactive: Nieaktywna + badges: + manual: Ręczna diff --git a/config/locales/views/registrations/de.yml b/config/locales/views/registrations/de.yml index f0747f360..f60f7d6ba 100644 --- a/config/locales/views/registrations/de.yml +++ b/config/locales/views/registrations/de.yml @@ -17,6 +17,7 @@ de: invitation_message: "%{inviter} hat dich eingeladen als %{role} beizutreten" join_family_title: "%{family} beitreten" role_admin: Administrator + role_guest: Gast role_member: Mitglied submit: Konto erstellen title: Erstelle dein Konto diff --git a/config/locales/views/registrations/es.yml b/config/locales/views/registrations/es.yml index fc4b1169d..fadc9e6d5 100644 --- a/config/locales/views/registrations/es.yml +++ b/config/locales/views/registrations/es.yml @@ -11,21 +11,21 @@ es: closed: Las inscripciones están actualmente cerradas. create: failure: Hubo un problema al registrarse. - invalid_invite_code: Código de invitación inválido, por favor inténtalo de nuevo. + invalid_invite_code: Código de invitación no válido, por favor inténtalo de nuevo. success: Te has registrado con éxito. new: invitation_message: "%{inviter} te ha invitado a unirte como %{role}" - join_family_title: Únete a %{family} + join_family_title: Únete a %{family} %{moniker} role_admin: administrador + role_guest: invitado role_member: miembro submit: Crear cuenta title: Crea tu cuenta - welcome_body: Para comenzar, debes registrarte para obtener una nueva cuenta. Luego podrás - configurar ajustes adicionales dentro de la aplicación. - welcome_title: ¡Bienvenido a Self Hosted %{product_name}! + welcome_body: Para comenzar, debes registrarte para obtener una nueva cuenta. Luego podrás configurar ajustes adicionales dentro de la aplicación. + welcome_title: ¡Bienvenido a %{product_name} (Self Hosted)! password_placeholder: Introduce tu contraseña password_requirements: length: Mínimo 8 caracteres case: Mayúsculas y minúsculas number: Un número (0-9) - special: "Un carácter especial (!, @, #, $, %, etc)" + special: "Un carácter especial (!, @, #, $, %, etc)" \ No newline at end of file diff --git a/config/locales/views/registrations/pl.yml b/config/locales/views/registrations/pl.yml new file mode 100644 index 000000000..c8cf9bac6 --- /dev/null +++ b/config/locales/views/registrations/pl.yml @@ -0,0 +1,31 @@ +--- +pl: + helpers: + label: + user: + invite_code: Kod zaproszenia + submit: + user: + create: Kontynuuj + registrations: + closed: Rejestracja jest obecnie zamknięta. + create: + failure: Wystąpił problem podczas rejestracji. + invalid_invite_code: Nieprawidłowy kod zaproszenia, spróbuj ponownie. + success: Rejestracja zakończyła się pomyślnie. + new: + invitation_message: "%{inviter} zaprosił(a) Cię do dołączenia jako %{role}" + join_family_title: Dołącz do %{family} %{moniker} + role_admin: administrator + role_guest: gość + role_member: członek + submit: Utwórz konto + title: Utwórz swoje konto + welcome_body: Aby rozpocząć, musisz zarejestrować nowe konto. Następnie będzie można skonfigurować dodatkowe ustawienia w aplikacji. + welcome_title: Witamy w Self Hosted %{product_name}! + password_placeholder: Wpisz hasło + password_requirements: + length: Minimum 8 znaków + case: Wielkie i małe litery + number: Liczba (0-9) + special: "Znak specjalny (!, @, #, $, %, itp.)" diff --git a/config/locales/views/reports/de.yml b/config/locales/views/reports/de.yml index 66f68d5a2..9baf148c1 100644 --- a/config/locales/views/reports/de.yml +++ b/config/locales/views/reports/de.yml @@ -5,6 +5,9 @@ de: title: Berichte subtitle: Umfassende Einblicke in deine finanzielle Situation export: CSV exportieren + print_report: Bericht drucken + drag_to_reorder: "Bereich per Drag & Drop neu anordnen" + toggle_section: "Sichtbarkeit des Bereichs umschalten" periods: monthly: Monatlich quarterly: Vierteljährlich @@ -45,6 +48,7 @@ de: budgeted: Budgetiert remaining: Verbleibend over_by: Überschritten um + shared: geteilt suggested_daily: "%{amount} pro Tag empfohlen für %{days} verbleibende Tage" no_budgets: Keine Budgetkategorien für diesen Monat eingerichtet status: @@ -80,6 +84,49 @@ de: description: Erfasse deine Finanzen, indem du Transaktionen hinzufügst oder deine Konten verbindest, um umfassende Berichte zu sehen add_transaction: Transaktion hinzufügen add_account: Konto hinzufügen + net_worth: + title: Nettovermögen + current_net_worth: Aktuelles Nettovermögen + period_change: Änderung im Zeitraum + assets_vs_liabilities: Vermögen vs. Verbindlichkeiten + total_assets: Vermögen + total_liabilities: Verbindlichkeiten + no_assets: Keine Vermögenswerte + no_liabilities: Keine Verbindlichkeiten + investment_performance: + title: Anlageperformance + portfolio_value: Portfoliowert + total_return: Gesamtrendite + contributions: Einlagen im Zeitraum + withdrawals: Entnahmen im Zeitraum + top_holdings: Top-Positionen + holding: Position + weight: Gewicht + value: Wert + return: Rendite + accounts: Anlagekonten + gains_by_tax_treatment: Gewinne nach Steuerbehandlung + unrealized_gains: Nicht realisierte Gewinne + realized_gains: Realisierte Gewinne + total_gains: Gesamtgewinne + taxable_realized_note: Diese Gewinne können steuerpflichtig sein + no_data: "-" + view_details: Details anzeigen + holdings_count: + one: "%{count} Position" + other: "%{count} Positionen" + sells_count: + one: "%{count} Verkauf" + other: "%{count} Verkäufe" + holdings: Positionen + sell_trades: Verkaufstrades + and_more: "+%{count} weitere" + investment_flows: + title: Anlageflüsse + description: Verfolge Geldflüsse in und aus deinen Anlagekonten + contributions: Einlagen + withdrawals: Entnahmen + net_flow: Nettozufluss transactions_breakdown: title: Transaktionsübersicht no_transactions: Keine Transaktionen für den ausgewählten Zeitraum und Filter gefunden @@ -114,10 +161,14 @@ de: expense: Ausgaben income: Einnahmen uncategorized: Ohne Kategorie - transactions: Transaktionen + entries: + one: "%{count} Eintrag" + other: "%{count} Einträge" percentage: "% des Gesamtbetrags" pagination: - showing: Zeige %{count} Transaktionen + showing: + one: Zeige %{count} Eintrag + other: Zeige %{count} Einträge previous: Zurück next: Weiter google_sheets_instructions: @@ -136,3 +187,52 @@ de: open_sheets: Google Sheets öffnen go_to_api_keys: Zu den API-Schlüsseln close: Verstanden + print: + document_title: Finanzbericht + title: Finanzbericht + generated_on: "Erstellt am %{date}" + summary: + title: Zusammenfassung + income: Einnahmen + expenses: Ausgaben + net_savings: Nettoersparnis + budget: Budget + vs_prior: "%{percent}% vs. Vorperiode" + of_income: "%{percent}% der Einnahmen" + used: genutzt + net_worth: + title: Nettovermögen + current_balance: Aktueller Kontostand + this_period: dieser Zeitraum + assets: Vermögen + liabilities: Verbindlichkeiten + no_liabilities: Keine Verbindlichkeiten + trends: + title: Monatliche Trends + month: Monat + income: Einnahmen + expenses: Ausgaben + net: Netto + savings_rate: Sparquote + average: Durchschnitt + current_month_note: "* Aktueller Monat (Teildaten)" + investments: + title: Anlagen + portfolio_value: Portfoliowert + total_return: Gesamtrendite + contributions: Einlagen + withdrawals: Entnahmen + this_period: dieser Zeitraum + top_holdings: Top-Positionen + holding: Position + weight: Gewicht + value: Wert + return: Rendite + spending: + title: Ausgaben nach Kategorie + income: Einnahmen + expenses: Ausgaben + category: Kategorie + amount: Betrag + percent: "%" + more_categories: "+ %{count} weitere Kategorien" diff --git a/config/locales/views/reports/es.yml b/config/locales/views/reports/es.yml index 8feab2eb5..00934b2fb 100644 --- a/config/locales/views/reports/es.yml +++ b/config/locales/views/reports/es.yml @@ -34,6 +34,7 @@ es: budgeted: Presupuestado remaining: Restante over_by: Exceso de + shared: compartido suggested_daily: "%{amount} sugerido por día durante los %{days} días restantes" no_budgets: No hay categorías de presupuesto configuradas para este mes status: @@ -134,6 +135,22 @@ es: value: Valor return: Rentabilidad accounts: Cuentas de inversión + gains_by_tax_treatment: Ganancias por tratamiento fiscal + unrealized_gains: Ganancias no realizadas + realized_gains: Ganancias realizadas + total_gains: Ganancias totales + taxable_realized_note: Estas ganancias pueden estar sujetas a impuestos + no_data: "-" + view_details: Ver detalles + holdings_count: + one: "%{count} activo" + other: "%{count} activos" + sells_count: + one: "%{count} venta" + other: "%{count} ventas" + holdings: Activos + sell_trades: Operaciones de venta + and_more: "+%{count} más" investment_flows: title: Flujos de inversión description: Controla el dinero que entra y sale de tus cuentas de inversión @@ -147,7 +164,7 @@ es: steps: "Para importar en Google Sheets:\n1. Crea una nueva hoja de cálculo\n2. En la celda A1, introduce la fórmula que se muestra abajo\n3. Pulsa Enter" security_warning: "Esta URL incluye tu clave API. ¡Mantenla segura!" need_key: Para importar los datos en Google Sheets necesitas una clave API. - step1: "Ve a ajustes → Clave API" + step1: "Ve a Ajustes → Claves API" step2: "Crea una nueva clave API con permiso de lectura (\"read\")" step3: Copia la clave API step4: "Añádela a esta URL como: ?api_key=TU_CLAVE" @@ -209,4 +226,4 @@ es: category: Categoría amount: Importe percent: "%" - more_categories: "+ %{count} más categorías" + more_categories: "+ %{count} más categorías" \ No newline at end of file diff --git a/config/locales/views/reports/pl.yml b/config/locales/views/reports/pl.yml new file mode 100644 index 000000000..3158c8c17 --- /dev/null +++ b/config/locales/views/reports/pl.yml @@ -0,0 +1,236 @@ +--- +pl: + reports: + index: + title: Raporty + subtitle: Kompleksowy wgląd w kondycję Twoich finansów + export: Eksportuj CSV + print_report: Drukuj raport + drag_to_reorder: Przeciągnij, aby zmienić kolejność sekcji + toggle_section: Przełącz widoczność sekcji + periods: + monthly: Miesięcznie + quarterly: Kwartalnie + ytd: Od początku roku + last_6_months: Ostatnie 6 miesięcy + custom: Własny zakres + date_range: + from: Od + to: Do + showing_period: Wyświetlanie danych od %{start} do %{end} + invalid_date_range: Data końcowa nie może być wcześniejsza niż data początkowa. Daty zostały zamienione. + summary: + total_income: Łączne przychody + total_expenses: Łączne wydatki + net_savings: Saldo oszczędności + budget_performance: Realizacja budżetu + vs_previous: względem poprzedniego okresu + income_minus_expenses: Przychody minus wydatki + of_budget_used: wykorzystanego budżetu + no_budget_data: Brak danych budżetowych dla tego okresu + budget_performance: + title: Realizacja budżetu + spent: Wydano + budgeted: Zaplanowano + remaining: Pozostało + over_by: Przekroczono o + shared: współdzielony + suggested_daily: "Sugerowane %{amount} dziennie przez pozostałe %{days} dni" + no_budgets: Nie skonfigurowano kategorii budżetowych na ten miesiąc + status: + good: Zgodnie z planem + warning: Blisko limitu + over: Przekroczono budżet + trends: + title: Trendy i wnioski + monthly_breakdown: Podział miesięczny + month: Miesiąc + income: Przychody + expenses: Wydatki + net: Saldo + savings_rate: Stopa oszczędzania + current: bieżący + avg_monthly_income: Śr. miesięczny przychód + avg_monthly_expenses: Śr. miesięczne wydatki + avg_monthly_savings: Śr. miesięczne oszczędności + no_data: Brak danych trendu + spending_patterns: Wzorce wydatków + weekday_spending: Wydatki w dni robocze + weekend_spending: Wydatki w weekendy + total: Łącznie + avg_per_transaction: Śr. na transakcję + transactions: Transakcje + insight_title: Wniosek + insight_higher_weekend: W weekendy wydajesz o %{percent}% więcej na transakcję niż w dni robocze + insight_higher_weekday: W dni robocze wydajesz o %{percent}% więcej na transakcję niż w weekendy + insight_similar: Twoje wydatki na transakcję są podobne w dni robocze i weekendy + no_spending_data: Brak danych o wydatkach dla tego okresu + empty_state: + title: Brak danych + description: Zacznij śledzić swoje finanse, dodając transakcje lub łącząc konta, aby zobaczyć pełne raporty + add_transaction: Dodaj transakcję + add_account: Dodaj konto + transactions_breakdown: + title: Podział aktywności + no_transactions: Brak aktywności dla wybranego okresu i filtrów + filters: + title: Filtry + category: Kategoria + account: Konto + tag: Tag + amount_min: Min. kwota + amount_max: Maks. kwota + date_range: Zakres dat + all_categories: Wszystkie kategorie + all_accounts: Wszystkie konta + all_tags: Wszystkie tagi + apply: Zastosuj filtry + clear: Wyczyść filtry + sort: + label: Sortuj według + date_desc: Data (od najnowszych) + amount_desc: Kwota (malejąco) + amount_asc: Kwota (rosnąco) + export: + label: Eksport + csv: CSV + excel: Excel + pdf: PDF + google_sheets: Otwórz w Google Sheets + table: + category: Kategoria + amount: Kwota + type: Typ + expense: Wydatki + income: Przychody + uncategorized: Bez kategorii + entries: + one: "%{count} wpis" + few: "%{count} wpisy" + many: "%{count} wpisów" + other: "%{count} wpisu" + percentage: "% całości" + pagination: + showing: + one: Wyświetlono %{count} wpis + few: Wyświetlono %{count} wpisy + many: Wyświetlono %{count} wpisów + other: Wyświetlono %{count} wpisu + previous: Poprzednia + next: Następna + net_worth: + title: Majątek netto + current_net_worth: Bieżący majątek netto + period_change: Zmiana w okresie + assets_vs_liabilities: Aktywa vs zobowiązania + total_assets: Aktywa + total_liabilities: Zobowiązania + no_assets: Brak aktywów + no_liabilities: Brak zobowiązań + investment_performance: + title: Wyniki inwestycji + portfolio_value: Wartość portfela + total_return: Łączny zwrot + contributions: Wpłaty w okresie + withdrawals: Wypłaty w okresie + top_holdings: Największe pozycje + holding: Pozycja + weight: Udział + value: Wartość + return: Zwrot + accounts: Konta inwestycyjne + gains_by_tax_treatment: Zyski według rodzaju opodatkowania + unrealized_gains: Niezrealizowane zyski + realized_gains: Zrealizowane zyski + total_gains: Łączne zyski + taxable_realized_note: Te zyski mogą podlegać opodatkowaniu + no_data: "-" + view_details: Zobacz szczegóły + holdings_count: + one: "%{count} pozycja" + few: "%{count} pozycje" + many: "%{count} pozycji" + other: "%{count} pozycji" + sells_count: + one: "%{count} sprzedaż" + few: "%{count} sprzedaże" + many: "%{count} sprzedaży" + other: "%{count} sprzedaży" + holdings: Pozycje + sell_trades: Transakcje sprzedaży + and_more: "+%{count} więcej" + investment_flows: + title: Przepływy inwestycyjne + description: Śledź środki wpływające i wypływające z kont inwestycyjnych + contributions: Wpłaty + withdrawals: Wypłaty + net_flow: Przepływ netto + google_sheets_instructions: + title_with_key: Skopiuj URL do Google Sheets + title_no_key: Wymagany klucz API + ready: Twój URL CSV (z kluczem API) jest gotowy. + steps: |- + Aby zaimportować do Google Sheets: + 1. Utwórz nowy arkusz Google + 2. W komórce A1 wpisz formułę pokazaną poniżej + 3. Naciśnij Enter + security_warning: Ten URL zawiera Twój klucz API. Przechowuj go bezpiecznie. + need_key: Aby importować dane do Google Sheets, potrzebujesz klucza API. + step1: Przejdź do Ustawienia → Klucze API + step2: Utwórz nowy klucz API z uprawnieniem "read" + step3: Skopiuj klucz API + step4: 'Dodaj go do tego URL-a tak: ?api_key=YOUR_KEY' + example: Przykład + then_use: Następnie użyj pełnego URL-a z =IMPORTDATA() w Google Sheets. + open_sheets: Otwórz Google Sheets + go_to_api_keys: Przejdź do kluczy API + close: Rozumiem + print: + document_title: Raport finansowy + title: Raport finansowy + generated_on: Wygenerowano %{date} + summary: + title: Podsumowanie + income: Przychody + expenses: Wydatki + net_savings: Saldo oszczędności + budget: Budżet + vs_prior: "%{percent}% względem poprzedniego" + of_income: "%{percent}% przychodu" + used: wykorzystano + net_worth: + title: Majątek netto + current_balance: Bieżące saldo + this_period: w tym okresie + assets: Aktywa + liabilities: Zobowiązania + no_liabilities: Brak zobowiązań + trends: + title: Trendy miesięczne + month: Miesiąc + income: Przychody + expenses: Wydatki + net: Saldo + savings_rate: Stopa oszczędzania + average: Średnia + current_month_note: "* Bieżący miesiąc (dane częściowe)" + investments: + title: Inwestycje + portfolio_value: Wartość portfela + total_return: Łączny zwrot + contributions: Wpłaty + withdrawals: Wypłaty + this_period: w tym okresie + top_holdings: Największe pozycje + holding: Pozycja + weight: Udział + value: Wartość + return: Zwrot + spending: + title: Wydatki według kategorii + income: Przychody + expenses: Wydatki + category: Kategoria + amount: Kwota + percent: "%" + more_categories: "+ %{count} więcej kategorii" diff --git a/config/locales/views/reports/pt-BR.yml b/config/locales/views/reports/pt-BR.yml index 915199949..d48dfe0f5 100644 --- a/config/locales/views/reports/pt-BR.yml +++ b/config/locales/views/reports/pt-BR.yml @@ -5,6 +5,7 @@ pt-BR: title: Relatórios subtitle: Insights completos sobre sua saúde financeira export: Exportar CSV + print_report: Imprimir Relatório drag_to_reorder: "Arraste para reordenar a seção" toggle_section: "Alternar visibilidade da seção" periods: @@ -33,6 +34,7 @@ pt-BR: budgeted: Orçado remaining: Restante over_by: Excedido em + shared: compartilhado suggested_daily: "%{amount} sugerido por dia para os %{days} dias restantes" no_budgets: Nenhuma categoria de orçamento configurada para este mês status: @@ -102,12 +104,59 @@ pt-BR: expense: Despesas income: Receitas uncategorized: Sem Categoria - transactions: transações + entries: + one: "%{count} entrada" + other: "%{count} entradas" percentage: "% do Total" pagination: - showing: Exibindo %{count} transações + showing: + one: Exibindo %{count} entrada + other: Exibindo %{count} entradas previous: Anterior next: Próximo + net_worth: + title: Patrimônio líquido + current_net_worth: Patrimônio líquido atual + period_change: Alteração de período + assets_vs_liabilities: Ativos versus Passivos + total_assets: Ativos + total_liabilities: Passivos + no_assets: Sem ativos + no_liabilities: Sem passivos + investment_performance: + title: Desempenho de Investimentos + portfolio_value: Valor do Portfólio + total_return: Retorno Total + contributions: Contribuições do Período + withdrawals: Saques do Período + top_holdings: Principais Aplicações + holding: Holding + weight: Peso + value: Valor + return: Retorno + accounts: Contas de Investimento + gains_by_tax_treatment: Ganhos por tratamento tributário + unrealized_gains: Ganhos Não Realizados + realized_gains: Ganhos Realizados + total_gains: Ganhos Totais + taxable_realized_note: Esses ganhos podem estar sujeitos a impostos + no_data: "-" + view_details: Ver detalhes + holdings_count: + one: "%{count} holding" + other: "%{count} holdings" + sells_count: + one: "%{count} venda" + other: "%{count} vendas" + holdings: Holdings + sell_trades: Venda de Negócios + and_more: "+%{count} mais" + investment_flows: + title: Fluxos de investimento + description: Acompanhe o fluxo de dinheiro que entra e sai de suas contas de investimento. + contributions: Contribuições + withdrawals: Saques + net_flow: Fluxo Líquido google_sheets_instructions: title_with_key: "✅ Copiar URL para Google Sheets" title_no_key: "⚠️ Chave de API Necessária" @@ -124,3 +173,57 @@ pt-BR: open_sheets: Abrir Google Sheets go_to_api_keys: Ir para Chaves de API close: Entendi + print: + document_title: Relatório Financeiro + title: Relatório Financeiro + generated_on: "Gerado em %{date}" + # Summary section + summary: + title: Resumo + income: Receita + expenses: Despesas + net_savings: Poupança Líquida + budget: Orçamento + vs_prior: "%{percent}% vs anterior" + of_income: "%{percent}% da Receita" + used: usado + # Net Worth section + net_worth: + title: Patrimônio Líquido + current_balance: Saldo Atual + this_period: neste período + assets: Ativos + liabilities: Passivos + no_liabilities: Sem passivos + # Monthly Trends section + trends: + title: Tendências Mensais + month: Mês + income: Receita + expenses: Despesas + net: Líquido + savings_rate: Taxa de Poupança + average: Média + current_month_note: "* Mês atual (dados parciais)" + # Investments section + investments: + title: Investimentos + portfolio_value: Valor do Portfólio + total_return: Retorno Total + contributions: Contribuições + withdrawals: Saques + this_period: este período + top_holdings: Principais Aplicações + holding: Holding + weight: Peso + value: Valor + return: Retorno + # Spending by Category section + spending: + title: Gastos por Categoria + income: Receita + expenses: Despesas + category: Categoria + amount: Quantia + percent: "%" + more_categories: "+ %{count} mais categorias" diff --git a/config/locales/views/rules/de.yml b/config/locales/views/rules/de.yml index cc57171b0..c6da99379 100644 --- a/config/locales/views/rules/de.yml +++ b/config/locales/views/rules/de.yml @@ -3,6 +3,21 @@ de: rules: no_action: Keine Aktion no_condition: Keine Bedingung + actions: + value_placeholder: Wert eingeben + apply_all: + button: Alle anwenden + confirm_title: Alle Regeln anwenden + confirm_message: Du bist dabei, %{count} Regeln anzuwenden, die %{transactions} eindeutige Transaktionen betreffen. Bitte bestätige, wenn du fortfahren möchtest. + confirm_button: Bestätigen und alle anwenden + success: Alle Regeln wurden zur Ausführung in die Warteschlange gestellt + ai_cost_title: KI-Kostenschätzung + ai_cost_message: Dies verwendet KI, um bis zu %{transactions} Transaktionen zu kategorisieren. + estimated_cost: "Geschätzte Kosten: ca. %{cost} $" + cost_unavailable_model: Kostenschätzung für Modell „%{model}“ nicht verfügbar. + cost_unavailable_no_provider: Kostenschätzung nicht verfügbar (kein LLM-Anbieter konfiguriert). + cost_warning: Es können Kosten entstehen. Bitte informiere dich beim Modellanbieter über die aktuellen Preise. + view_usage: Nutzungshistorie anzeigen recent_runs: title: Letzte Ausführungen description: Zeige die Ausführungsgeschichte deiner Regeln einschließlich Erfolgs-/Fehlerstatus und Transaktionsanzahlen. @@ -23,3 +38,15 @@ de: pending: Ausstehend success: Erfolgreich failed: Fehlgeschlagen + clear_ai_cache: + button: KI-Cache zurücksetzen + confirm_title: KI-Cache zurücksetzen? + confirm_body: Bist du sicher, dass du den KI-Cache zurücksetzen möchtest? Dadurch können KI-Regeln alle Transaktionen erneut verarbeiten. Dies kann zusätzliche API-Kosten verursachen. + confirm_button: Cache zurücksetzen + success: Der KI-Cache wird geleert. Das kann einen Moment dauern. + condition_filters: + transaction_type: + income: Einnahme + expense: Ausgabe + transfer: Überweisung + equal_to: Gleich diff --git a/config/locales/views/rules/es.yml b/config/locales/views/rules/es.yml index 3c494e8fb..2390c7676 100644 --- a/config/locales/views/rules/es.yml +++ b/config/locales/views/rules/es.yml @@ -3,6 +3,21 @@ es: rules: no_action: Sin acción no_condition: Sin condición + actions: + value_placeholder: Introduce un valor + apply_all: + button: Aplicar todas + confirm_title: Aplicar todas las reglas + confirm_message: Estás a punto de aplicar %{count} reglas que afectan a %{transactions} transacciones únicas. Por favor, confirma si deseas continuar. + confirm_button: Confirmar y aplicar todas + success: Todas las reglas han sido puestas en cola para su ejecución + ai_cost_title: Estimación de costes de IA + ai_cost_message: Esto utilizará IA para categorizar hasta %{transactions} transacciones. + estimated_cost: "Coste estimado: ~$%{cost}" + cost_unavailable_model: Estimación de costes no disponible para el modelo "%{model}". + cost_unavailable_no_provider: Estimación de costes no disponible (no hay proveedor de LLM configurado). + cost_warning: Puedes incurrir en costes, consulta con el proveedor del modelo los precios más actualizados. + view_usage: Ver historial de uso recent_runs: title: Ejecuciones Recientes description: Ver el historial de ejecución de tus reglas incluyendo el estado de éxito/fallo y los conteos de transacciones. @@ -23,3 +38,15 @@ es: pending: Pendiente success: Éxito failed: Fallido + clear_ai_cache: + button: Restablecer caché de IA + confirm_title: ¿Restablecer caché de IA? + confirm_body: ¿Estás seguro de que deseas restablecer la caché de IA? Esto permitirá que las reglas de IA vuelvan a procesar todas las transacciones. Esto puede incurrir en costes adicionales de API. + confirm_button: Restablecer caché + success: Se está limpiando la caché de IA. Esto puede tardar unos momentos. + condition_filters: + transaction_type: + income: Ingreso + expense: Gasto + transfer: Transferencia + equal_to: Igual a diff --git a/config/locales/views/rules/pl.yml b/config/locales/views/rules/pl.yml new file mode 100644 index 000000000..a891c5627 --- /dev/null +++ b/config/locales/views/rules/pl.yml @@ -0,0 +1,52 @@ +--- +pl: + rules: + no_action: Brak akcji + no_condition: Brak warunku + actions: + value_placeholder: Wprowadź wartość + apply_all: + button: Zastosuj wszystkie + confirm_title: Zastosować wszystkie reguły + confirm_message: Za chwilę zastosujesz %{count} reguł wpływających na %{transactions} unikalnych transakcji. Potwierdź, jeśli chcesz kontynuować. + confirm_button: Potwierdź i zastosuj wszystkie + success: Wszystkie reguły zostały dodane do kolejki wykonania + ai_cost_title: Szacowanie kosztu AI + ai_cost_message: To użyje AI do kategoryzacji maksymalnie %{transactions} transakcji. + estimated_cost: 'Szacowany koszt: ~$%{cost}' + cost_unavailable_model: Szacowanie kosztu niedostępne dla modelu "%{model}". + cost_unavailable_no_provider: Szacowanie kosztu niedostępne (brak skonfigurowanego dostawcy LLM). + cost_warning: Mogą zostać naliczone koszty, sprawdź u dostawcy modelu aktualny cennik. + view_usage: Zobacz historię użycia + recent_runs: + title: Ostatnie uruchomienia + description: Zobacz historię wykonania reguł, w tym status powodzenia/niepowodzenia oraz liczbę transakcji. + unnamed_rule: Reguła bez nazwy + columns: + date_time: Data/godzina + execution_type: Typ + status: Status + rule_name: Nazwa reguły + transactions_counts: + queued: W kolejce + processed: Przetworzone + modified: Zmodyfikowane + execution_types: + manual: Ręczne + scheduled: Zaplanowane + statuses: + pending: Oczekujące + success: Pomyślnie + failed: Błąd + clear_ai_cache: + button: Zresetuj cache AI + confirm_title: Zresetować cache AI? + confirm_body: Czy na pewno chcesz zresetować cache AI? Umożliwi to regułom AI ponowne przetworzenie wszystkich transakcji. Może to wygenerować dodatkowe koszty API. + confirm_button: Zresetuj cache + success: Trwa czyszczenie cache AI. To może potrwać chwilę. + condition_filters: + transaction_type: + income: Przychód + expense: Wydatek + transfer: Przelew + equal_to: Równe diff --git a/config/locales/views/securities/es.yml b/config/locales/views/securities/es.yml new file mode 100644 index 000000000..2ccd9cdd4 --- /dev/null +++ b/config/locales/views/securities/es.yml @@ -0,0 +1,6 @@ +--- +es: + securities: + combobox: + display: "%{symbol} - %{name} (%{exchange})" + exchange_label: "%{symbol} (%{exchange})" \ No newline at end of file diff --git a/config/locales/views/securities/pl.yml b/config/locales/views/securities/pl.yml new file mode 100644 index 000000000..5a3d8655d --- /dev/null +++ b/config/locales/views/securities/pl.yml @@ -0,0 +1,6 @@ +--- +pl: + securities: + combobox: + display: "%{symbol} - %{name} (%{exchange})" + exchange_label: "%{symbol} (%{exchange})" diff --git a/config/locales/views/sessions/de.yml b/config/locales/views/sessions/de.yml index 1d307a7de..f472825b0 100644 --- a/config/locales/views/sessions/de.yml +++ b/config/locales/views/sessions/de.yml @@ -3,12 +3,19 @@ de: sessions: create: invalid_credentials: Ungültige E-Mail-Adresse oder falsches Passwort. + local_login_disabled: Anmeldung mit Passwort ist deaktiviert. Bitte nutze Single Sign-On. destroy: logout_successful: Du hast dich erfolgreich abgemeldet. + post_logout: + logout_successful: Du hast dich erfolgreich abgemeldet. openid_connect: + account_linked: "Konto erfolgreich mit %{provider} verknüpft" failed: Anmeldung über OpenID Connect fehlgeschlagen. failure: failed: Anmeldung fehlgeschlagen. + sso_provider_unavailable: "Der SSO-Anbieter ist derzeit nicht verfügbar. Bitte versuche es später erneut oder wende dich an einen Administrator." + sso_invalid_response: "Vom SSO-Anbieter wurde eine ungültige Antwort erhalten. Bitte versuche es erneut." + sso_failed: "Single Sign-On-Anmeldung fehlgeschlagen. Bitte versuche es erneut." new: email: E-Mail-Adresse email_placeholder: du@beispiel.de @@ -20,3 +27,7 @@ de: openid_connect: Mit OpenID Connect anmelden oidc: Mit OpenID Connect anmelden google_auth_connect: Mit Google anmelden + local_login_admin_only: Lokale Anmeldung ist auf Administratoren beschränkt. + no_auth_methods_enabled: Derzeit sind keine Anmeldemethoden aktiviert. Bitte wende dich an einen Administrator. + demo_banner_title: "Demo-Modus aktiv" + demo_banner_message: "Dies ist eine Demo-Umgebung. Anmeldedaten wurden zur Vereinfachung vorausgefüllt. Bitte gib keine echten oder sensiblen Daten ein." diff --git a/config/locales/views/sessions/es.yml b/config/locales/views/sessions/es.yml index d7dbbee6b..1e6cc34d2 100644 --- a/config/locales/views/sessions/es.yml +++ b/config/locales/views/sessions/es.yml @@ -2,24 +2,32 @@ es: sessions: create: - invalid_credentials: Correo electrónico o contraseña inválidos. - local_login_disabled: El inicio de sesión con contraseña local está deshabilitado. Utiliza el inicio de sesión único (SSO). + invalid_credentials: Correo electrónico o contraseña no válidos. + local_login_disabled: El inicio de sesión con contraseña local está desactivado. Por favor, utiliza el inicio de sesión único (SSO). destroy: - logout_successful: Has cerrado sesión con éxito. + logout_successful: Has cerrado sesión correctamente. + post_logout: + logout_successful: Has cerrado sesión correctamente. openid_connect: + account_linked: "Cuenta vinculada correctamente a %{provider}" failed: No se pudo autenticar a través de OpenID Connect. failure: failed: No se pudo autenticar. + sso_provider_unavailable: "El proveedor de SSO no está disponible en este momento. Por favor, inténtalo de nuevo más tarde o contacta con un administrador." + sso_invalid_response: "Se ha recibido una respuesta no válida del proveedor de SSO. Por favor, inténtalo de nuevo." + sso_failed: "Error en la autenticación de inicio de sesión único (SSO). Por favor, inténtalo de nuevo." new: email: Dirección de correo electrónico email_placeholder: tu@ejemplo.com - forgot_password: ¿Olvidaste tu contraseña? + forgot_password: ¿Has olvidado tu contraseña? password: Contraseña submit: Iniciar sesión - title: Inicia sesión en tu cuenta + title: Sure password_placeholder: Introduce tu contraseña - openid_connect: Inicia sesión con OpenID Connect - oidc: Inicia sesión con OpenID Connect - google_auth_connect: Inicia sesión con Google + openid_connect: Iniciar sesión con OpenID Connect + oidc: Iniciar sesión con OpenID Connect + google_auth_connect: Iniciar sesión con Google local_login_admin_only: El inicio de sesión local está restringido a administradores. - no_auth_methods_enabled: No hay métodos de autenticación habilitados actualmente. Ponte en contacto con un administrador. + no_auth_methods_enabled: No hay métodos de autenticación habilitados actualmente. Por favor, contacta con un administrador. + demo_banner_title: "Modo de demostración activo" + demo_banner_message: "Este es un entorno de demostración. Las credenciales de acceso se han rellenado automáticamente para tu comodidad. Por favor, no introduzcas información real o sensible." \ No newline at end of file diff --git a/config/locales/views/sessions/pl.yml b/config/locales/views/sessions/pl.yml new file mode 100644 index 000000000..7fac5305f --- /dev/null +++ b/config/locales/views/sessions/pl.yml @@ -0,0 +1,33 @@ +--- +pl: + sessions: + create: + invalid_credentials: Nieprawidłowy adres e-mail lub hasło. + local_login_disabled: Logowanie lokalnym hasłem jest wyłączone. Użyj logowania jednokrotnego (SSO). + destroy: + logout_successful: Zostałeś(aś) pomyślnie wylogowany(a). + post_logout: + logout_successful: Zostałeś(aś) pomyślnie wylogowany(a). + openid_connect: + account_linked: "Konto zostało pomyślnie połączone z %{provider}" + failed: Nie udało się uwierzytelnić przez OpenID Connect. + failure: + failed: Nie udało się uwierzytelnić. + sso_provider_unavailable: "Dostawca SSO jest obecnie niedostępny. Spróbuj ponownie później lub skontaktuj się z administratorem." + sso_invalid_response: "Otrzymano nieprawidłową odpowiedź od dostawcy SSO. Spróbuj ponownie." + sso_failed: "Uwierzytelnianie SSO nie powiodło się. Spróbuj ponownie." + new: + email: Adres e-mail + email_placeholder: ty@example.com + forgot_password: Nie pamiętasz hasła? + password: Hasło + submit: Zaloguj się + title: Sure + password_placeholder: Wpisz hasło + openid_connect: Zaloguj się przez OpenID Connect + oidc: Zaloguj się przez OpenID Connect + google_auth_connect: Zaloguj się przez Google + local_login_admin_only: Logowanie lokalne jest ograniczone do administratorów. + no_auth_methods_enabled: Obecnie nie włączono żadnej metody uwierzytelniania. Skontaktuj się z administratorem. + demo_banner_title: "Aktywny tryb demo" + demo_banner_message: "To jest środowisko demonstracyjne. Dane logowania zostały wstępnie uzupełnione dla wygody. Nie wprowadzaj prawdziwych ani wrażliwych informacji." diff --git a/config/locales/views/settings/api_keys/pl.yml b/config/locales/views/settings/api_keys/pl.yml new file mode 100644 index 000000000..bef95529c --- /dev/null +++ b/config/locales/views/settings/api_keys/pl.yml @@ -0,0 +1,76 @@ +--- +pl: + settings: + api_keys_controller: + success: Twój klucz API został pomyślnie utworzony + revoked_successfully: Klucz API został pomyślnie unieważniony + revoke_failed: Nie udało się unieważnić klucza API + scope_descriptions: + read_accounts: Podgląd kont + read_transactions: Podgląd transakcji + read_balances: Podgląd sald + write_transactions: Tworzenie transakcji + api_keys: + show: + title: Zarządzanie kluczami API + no_api_key: + title: Klucz API + heading: Uzyskaj programowy dostęp do danych konta + description: Uzyskaj programowy dostęp do danych Sure za pomocą bezpiecznego klucza API. + what_you_can_do: 'Co możesz zrobić przez API:' + feature_1: Uzyskiwać programowy dostęp do danych konta + feature_2: Budować własne integracje i aplikacje + feature_3: Automatyzować pobieranie i analizę danych + security_note_title: Bezpieczeństwo przede wszystkim + security_note: Twój klucz API będzie miał ograniczone uprawnienia zależne od wybranych zakresów. Jednocześnie możesz mieć tylko jeden aktywny klucz API. + create_api_key: Utwórz klucz API + current_api_key: + title: Twój klucz API + description: Twój aktywny klucz API jest gotowy do użycia. Przechowuj go bezpiecznie i nigdy nie udostępniaj publicznie. + active: Aktywny + key_name: Nazwa + created_at: Utworzono + last_used: Ostatnie użycie + expires: Wygasa + ago: temu + never_used: Nigdy nie użyto + never_expires: Nigdy nie wygasa + permissions: Uprawnienia + usage_instructions_title: Jak używać klucza API + usage_instructions: 'Dołącz klucz API w nagłówku X-Api-Key podczas wysyłania żądań do API Maybe:' + regenerate_key: Utwórz nowy klucz + revoke_key: Unieważnij klucz + revoke_confirmation: Czy na pewno chcesz unieważnić ten klucz API? Tej akcji nie można cofnąć, a wszystkie aplikacje używające tego klucza zostaną natychmiast wyłączone. + new: + title: Utwórz klucz API + create_new_key: Utwórz nowy klucz API + description: Skonfiguruj nowy klucz API, nadając mu opisową nazwę i odpowiednie uprawnienia. + name_label: Nazwa klucza API + name_placeholder: np. Aplikacja produkcyjna, Dashboard analityczny + name_help: Wybierz opisową nazwę, aby łatwo rozpoznać przeznaczenie tego klucza. + permissions_label: Uprawnienia + permissions_help: Wybierz uprawnienia potrzebne dla klucza API. Zawsze możesz utworzyć nowy klucz z innymi uprawnieniami. + scope_details: + read_accounts: Podgląd informacji o kontach, sald i danych na poziomie konta + read_transactions: Podgląd danych transakcyjnych, kategorii i szczegółów transakcji + read_balances: Podgląd historycznych danych salda i trendów wartości kont + write_transactions: Tworzenie i aktualizacja rekordów transakcji (wkrótce) + security_warning_title: Ważna informacja o bezpieczeństwie + security_warning: Klucz API zostanie wyświetlony tylko raz po utworzeniu. Przechowuj go bezpiecznie i nigdy nie udostępniaj publicznie. Jeśli go zgubisz, trzeba będzie utworzyć nowy. + create_key: Utwórz klucz API + cancel: Anuluj + created: + title: Klucz API utworzony + success_title: Klucz API został pomyślnie utworzony + success_description: Twój nowy klucz API jest gotowy do użycia. Skopiuj go teraz, ponieważ później nie będzie już widoczny. + your_api_key: Twój klucz API + key_name: Nazwa + permissions: Uprawnienia + critical_warning_title: "⚠️ Ważne: zapisz klucz API teraz" + critical_warning_1: To jedyny moment, kiedy zobaczysz klucz API w postaci jawnej. + critical_warning_2: Skopiuj go i przechowuj bezpiecznie w menedżerze haseł lub aplikacji. + critical_warning_3: Jeśli zgubisz ten klucz, trzeba będzie utworzyć nowy. + usage_instructions_title: Szybki start + usage_instructions: 'Użyj klucza API, dodając go w nagłówku X-Api-Key:' + copy_key: Skopiuj klucz API + continue: Przejdź do ustawień klucza API diff --git a/config/locales/views/settings/de.yml b/config/locales/views/settings/de.yml index 708145ff9..e8a64d97e 100644 --- a/config/locales/views/settings/de.yml +++ b/config/locales/views/settings/de.yml @@ -3,6 +3,7 @@ de: settings: payments: renewal: "Ihr Beitrag wird fortgesetzt am %{date}." + cancellation: "Ihr Beitrag endet am %{date}." settings: ai_prompts: show: @@ -42,6 +43,9 @@ de: theme_system: System theme_title: Design timezone: Zeitzone + month_start_day: Budgetmonat beginnt am + month_start_day_hint: Lege fest, wann dein Budgetmonat beginnt (z. B. Gehaltstag) + month_start_day_warning: Deine Budgets und MTD-Berechnungen verwenden diesen benutzerdefinierten Starttag anstelle des 1. jedes Monats. profiles: destroy: cannot_remove_self: Du kannst dich nicht selbst aus dem Konto entfernen. @@ -73,6 +77,9 @@ de: reset_account_with_sample_data_warning: Löscht alle vorhandenen Daten und lädt anschließend neue Beispieldaten, um eine vorbefüllte Umgebung zu erkunden. email: E-Mail first_name: Vorname + group_form_input_placeholder: Gruppennamen eingeben + group_form_label: Gruppenname + group_title: Gruppenmitglieder household_form_input_placeholder: Haushaltsnamen eingeben household_form_label: Haushaltsname household_subtitle: Lade Familienmitglieder, Partner oder andere Personen ein. Eingeladene können sich in deinen Haushalt einloggen und auf gemeinsame Konten zugreifen. @@ -90,6 +97,23 @@ de: securities: show: page_title: Sicherheit + mfa_title: Zwei-Faktor-Authentifizierung + mfa_description: Erhöhe die Sicherheit deines Kontos, indem du bei der Anmeldung einen Code von deiner Authenticator-App verlangst + enable_mfa: 2FA aktivieren + disable_mfa: 2FA deaktivieren + disable_mfa_confirm: Bist du sicher, dass du die Zwei-Faktor-Authentifizierung deaktivieren möchtest? + sso_title: Verbundene Konten + sso_subtitle: Verwalte deine Single Sign-On-Kontoverbindungen + sso_disconnect: Trennen + sso_last_used: Zuletzt verwendet + sso_never: Nie + sso_no_email: Keine E-Mail + sso_no_identities: Keine SSO-Konten verbunden + sso_connect_hint: Melde dich ab und mit einem SSO-Anbieter an, um ein Konto zu verbinden. + sso_confirm_title: Konto trennen? + sso_confirm_body: Bist du sicher, dass du dein %{provider}-Konto trennen möchtest? Du kannst es später erneut verbinden, indem du dich erneut mit diesem Anbieter anmeldest. + sso_confirm_button: Trennen + sso_warning_message: Dies ist deine einzige Anmeldemethode. Du solltest vor dem Trennen ein Passwort in den Sicherheitseinstellungen festlegen, sonst könntest du dich aus deinem Konto ausschließen. settings_nav: accounts_label: Konten advanced_section_title: Erweitert @@ -124,3 +148,27 @@ de: choose: Foto hochladen choose_label: (optional) change: Foto ändern + providers: + show: + coinbase_title: Coinbase + encryption_error: + title: Verschlüsselungskonfiguration erforderlich + message: Die Active-Record-Verschlüsselungsschlüssel sind nicht konfiguriert. Bitte stelle die Verschlüsselungszugangsdaten (active_record_encryption.primary_key, active_record_encryption.deterministic_key und active_record_encryption.key_derivation_salt) in deinen Rails-Zugangsdaten oder Umgebungsvariablen ein, bevor du Sync-Anbieter verwendest. + coinbase_panel: + setup_instructions: "So verbindest du Coinbase:" + step1_html: Gehe zu Coinbase API-Einstellungen + step2: Erstelle einen neuen API-Schlüssel mit Leseberechtigung (Konten anzeigen, Transaktionen anzeigen) + step3: Kopiere deinen API-Schlüssel und dein API-Geheimnis und füge sie unten ein + api_key_label: API-Schlüssel + api_key_placeholder: Gib deinen Coinbase-API-Schlüssel ein + api_secret_label: API-Geheimnis + api_secret_placeholder: Gib dein Coinbase-API-Geheimnis ein + connect_button: Coinbase verbinden + syncing: Wird synchronisiert… + sync: Synchronisieren + disconnect_confirm: Bist du sicher, dass du diese Coinbase-Verbindung trennen möchtest? Deine synchronisierten Konten werden zu manuellen Konten. + status_connected: Coinbase ist verbunden und synchronisiert deine Krypto-Bestände. + status_not_connected: Nicht verbunden. Gib deine API-Zugangsdaten oben ein, um zu starten. + enable_banking_panel: + callback_url_instruction: "Für die Callback-URL, verwende %{callback_url}." + connection_error: Verbindungsfehler diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index e0030605a..02eda5228 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -26,6 +26,22 @@ en: page_title: Payments subscription_subtitle: Update your credit card details subscription_title: Manage contributions + appearances: + show: + page_title: Appearance + theme_title: Theme + theme_subtitle: Choose a preferred theme for the app + theme_dark: Dark + theme_light: Light + theme_system: System + transactions_title: Transactions + transactions_subtitle: Customize how transactions are displayed + dashboard_title: Dashboard + dashboard_subtitle: Customize how the dashboard is displayed + dashboard_two_column_title: Two-column layout + dashboard_two_column_description: Display dashboard widgets in two columns on large screens. When off, widgets stack in a single column. + split_grouped_title: Group split transactions + split_grouped_description: Show split transactions grouped under their parent in the transaction list. When off, split children appear as individual rows. preferences: show: country: Country @@ -38,15 +54,15 @@ en: language: Language language_auto: Browser language page_title: Preferences - theme_dark: Dark - theme_light: Light - theme_subtitle: Choose a preferred theme for the app - theme_system: System - theme_title: Theme timezone: Timezone month_start_day: Budget month starts on month_start_day_hint: Set when your budget month starts (e.g., payday) month_start_day_warning: Your budgets and MTD calculations will use this custom start day instead of the 1st of each month. + sharing_title: "%{moniker} Sharing" + sharing_subtitle: "Control how accounts are shared in your %{moniker}" + sharing_default_label: Default sharing for new accounts + sharing_shared: Share with all members + sharing_private: Keep private by default profiles: destroy: cannot_remove_self: You cannot remove yourself from the account. @@ -142,6 +158,7 @@ en: transactions_section_title: Transactions whats_new_label: What's new api_keys_label: API Key + appearance_label: Appearance bank_sync_label: Bank Sync settings_nav_link_large: next: Next @@ -172,5 +189,25 @@ en: disconnect_confirm: Are you sure you want to disconnect this Coinbase connection? Your synced accounts will become manual accounts. status_connected: Coinbase is connected and syncing your crypto holdings. status_not_connected: Not connected. Enter your API credentials above to get started. + binance_panel: + setup_instructions: "To connect Binance, create a read-only API key:" + step1_html: 'Go to Binance API Management' + step2: "Create a new API key with Enable Reading permission only" + step3: "Paste your API Key and Secret below" + no_withdraw_warning: "Warning: do NOT enable withdrawal permissions" + ip_hint_title: "IP Whitelisting Required" + ip_hint_body: "Add the app server's egress IP to the Binance API Key whitelist:" + ip_hint_contact_admin: "Contact your administrator to obtain the app server's egress IP address." + api_key_label: API Key + api_key_placeholder: Paste your Binance API Key + api_secret_label: API Secret + api_secret_placeholder: Paste your Binance API Secret + connect_button: Connect Binance + syncing: Syncing... + sync: Sync + disconnect_confirm: "Are you sure you want to disconnect Binance?" + status_connected: Binance connected + status_not_connected: Binance not connected enable_banking_panel: + callback_url_instruction: "For the callback URL, use %{callback_url}." connection_error: Connection Error diff --git a/config/locales/views/settings/es.yml b/config/locales/views/settings/es.yml index 1867ac49e..b9346a547 100644 --- a/config/locales/views/settings/es.yml +++ b/config/locales/views/settings/es.yml @@ -4,6 +4,7 @@ es: settings: payments: renewal: "Tu contribución continúa el %{date}." + cancellation: "Tu contribución finaliza el %{date}." settings: ai_prompts: show: @@ -22,9 +23,9 @@ es: subtitle: La IA identifica y enriquece los datos de transacciones con información de comerciantes payments: show: - page_title: Pago - subscription_subtitle: Actualiza tu suscripción y detalles de pago - subscription_title: Gestionar suscripción + page_title: Pagos + subscription_subtitle: Actualiza los detalles de tu tarjeta de crédito + subscription_title: Gestionar contribuciones preferences: show: country: País @@ -43,6 +44,9 @@ es: theme_system: Sistema theme_title: Tema timezone: Zona horaria + month_start_day: El mes de presupuesto comienza el + month_start_day_hint: Establece cuándo empieza tu mes financiero (ej. el día de cobro) + month_start_day_warning: Tus presupuestos y cálculos del mes en curso utilizarán este día personalizado en lugar del día 1 de cada mes. profiles: destroy: cannot_remove_self: No puedes eliminarte a ti mismo de la cuenta. @@ -74,9 +78,12 @@ es: reset_account_with_sample_data_warning: Elimina todos tus datos existentes y luego carga nuevos datos de ejemplo para que puedas explorar con un entorno prellenado. email: Correo electrónico first_name: Nombre + group_form_input_placeholder: Introduce el nombre del grupo + group_form_label: Nombre del grupo + group_title: Miembros del Grupo household_form_input_placeholder: Introduce el nombre del grupo familiar household_form_label: Nombre del grupo familiar - household_subtitle: Invita a miembros de la familia, socios y otras personas. Los invitados pueden entrar en la cuenta y acceder a las cuentas compartidas. + household_subtitle: Los invitados pueden entrar en tu cuenta de %{moniker} y acceder a los recursos compartidos. household_title: Grupo Familiar invitation_link: Enlace de invitación invite_member: Añadir miembro @@ -91,6 +98,23 @@ es: securities: show: page_title: Seguridad + mfa_title: Autenticación de Dos Factores (2FA) + mfa_description: Añade una capa extra de seguridad a tu cuenta requiriendo un código de tu aplicación de autenticación al iniciar sesión. + enable_mfa: Activar 2FA + disable_mfa: Desactivar 2FA + disable_mfa_confirm: ¿Estás seguro de que deseas desactivar la autenticación de dos factores? + sso_title: Cuentas Conectadas + sso_subtitle: Gestiona tus conexiones de inicio de sesión único (SSO) + sso_disconnect: Desconectar + sso_last_used: Último uso + sso_never: Nunca + sso_no_email: Sin correo + sso_no_identities: No hay cuentas de SSO conectadas + sso_connect_hint: Cierra sesión e iníciala con un proveedor de SSO para conectar una cuenta. + sso_confirm_title: ¿Desconectar cuenta? + sso_confirm_body: ¿Estás seguro de que deseas desconectar tu cuenta de %{provider}? Podrás volver a conectarla más tarde iniciando sesión de nuevo con ese proveedor. + sso_confirm_button: Desconectar + sso_warning_message: Este es tu único método de acceso. Deberías establecer una contraseña en tus ajustes de seguridad antes de desconectarlo, de lo contrario podrías perder el acceso a tu cuenta. settings_nav: accounts_label: Cuentas advanced_section_title: Avanzado @@ -125,3 +149,27 @@ es: choose: Subir foto choose_label: (opcional) change: Cambiar foto + providers: + show: + coinbase_title: Coinbase + encryption_error: + title: Configuración de Cifrado Requerida + message: Las claves de cifrado de Active Record no están configuradas. Por favor, asegúrate de que las credenciales de cifrado (active_record_encryption.primary_key, active_record_encryption.deterministic_key y active_record_encryption.key_derivation_salt) estén correctamente configuradas en tus credenciales de Rails o variables de entorno antes de usar proveedores de sincronización. + coinbase_panel: + setup_instructions: "Para conectar Coinbase:" + step1_html: Ve a los Ajustes de API de Coinbase + step2: Crea una nueva clave API con permisos de solo lectura (ver cuentas, ver transacciones) + step3: Copia tu clave API y tu secreto de API y pégalos a continuación + api_key_label: Clave API + api_key_placeholder: Introduce tu clave API de Coinbase + api_secret_label: Secreto de API + api_secret_placeholder: Introduce tu secreto de API de Coinbase + connect_button: Conectar Coinbase + syncing: Sincronizando... + sync: Sincronizar + disconnect_confirm: ¿Estás seguro de que deseas desconectar esta conexión de Coinbase? Tus cuentas sincronizadas pasarán a ser cuentas manuales. + status_connected: Coinbase está conectado y sincronizando tus activos de criptomonedas. + status_not_connected: No conectado. Introduce tus credenciales de API arriba para comenzar. + enable_banking_panel: + callback_url_instruction: "Para la URL de retorno (callback), utiliza %{callback_url}." + connection_error: Error de conexión \ No newline at end of file diff --git a/config/locales/views/settings/hostings/de.yml b/config/locales/views/settings/hostings/de.yml index cef56500f..322560e6c 100644 --- a/config/locales/views/settings/hostings/de.yml +++ b/config/locales/views/settings/hostings/de.yml @@ -16,6 +16,7 @@ de: show: general: Allgemeine Einstellungen financial_data_providers: Finanzdatenanbieter + sync_settings: Synchronisierungseinstellungen invites: Einladungscodes title: Self-Hosting danger_zone: Gefahrenbereich @@ -24,11 +25,22 @@ de: confirm_clear_cache: title: Daten-Cache leeren? body: Bist du sicher, dass du den Daten-Cache leeren möchtest? Dadurch werden alle Wechselkurse, Wertpapierpreise, Kontostände und andere Daten entfernt. Diese Aktion kann nicht rückgängig gemacht werden. + provider_selection: + title: Anbieterauswahl + description: Wähle, welcher Dienst für Wechselkurse und Wertpapierpreise verwendet werden soll. Yahoo Finance ist kostenlos und benötigt keinen API-Schlüssel. Twelve Data erfordert einen kostenlosen API-Schlüssel, bietet aber möglicherweise eine bessere Datenabdeckung. + exchange_rate_provider_label: Wechselkursanbieter + securities_provider_label: Wertpapiere (Aktienkurse) Anbieter + env_configured_message: Die Anbieterauswahl ist deaktiviert, weil Umgebungsvariablen (EXCHANGE_RATE_PROVIDER oder SECURITIES_PROVIDER) gesetzt sind. Um die Auswahl hier zu aktivieren, entferne diese Umgebungsvariablen aus deiner Konfiguration. + providers: + twelve_data: Twelve Data + yahoo_finance: Yahoo Finance brand_fetch_settings: description: Gib die von Brand Fetch bereitgestellte Client-ID ein. label: Client-ID placeholder: Gib hier deine Client-ID ein title: Brand Fetch Einstellungen + high_res_label: Hochauflösende Logos aktivieren + high_res_description: Wenn aktiviert, werden Logos in 120x120 statt 40x40 abgerufen. Das liefert schärfere Bilder auf hochauflösenden Displays. openai_settings: description: Gib dein Zugriffstoken ein und konfiguriere optional einen benutzerdefinierten, OpenAI-kompatiblen Anbieter. env_configured_message: Erfolgreich über Umgebungsvariablen konfiguriert. @@ -38,6 +50,12 @@ de: uri_base_placeholder: "https://api.openai.com/v1 (Standard)" model_label: Modell (optional) model_placeholder: "gpt-4.1 (Standard)" + json_mode_label: JSON-Modus + json_mode_auto: Auto (empfohlen) + json_mode_strict: Streng (am besten für Denk-Modelle) + json_mode_none: Keiner (am besten für Standard-Modelle) + json_mode_json_object: JSON-Objekt + json_mode_help: "Der strenge Modus funktioniert am besten mit Denk-Modellen (qwen-thinking, deepseek-reasoner). Der Modus Keiner funktioniert am besten mit Standard-Modellen (llama, mistral, gpt-oss)." title: OpenAI yahoo_finance_settings: title: Yahoo Finance @@ -53,11 +71,25 @@ de: label: API-Schlüssel placeholder: Gib hier deinen API-Schlüssel ein plan: "%{plan}-Tarif" + plan_upgrade_warning_title: Einige Ticker erfordern einen kostenpflichtigen Tarif + plan_upgrade_warning_description: Die folgenden Ticker in deinem Portfolio können mit deinem aktuellen Twelve-Data-Tarif keine Kurse synchronisieren. + requires_plan: erfordert %{plan}-Tarif + view_pricing: Twelve-Data-Preise anzeigen title: Twelve Data update: failure: Ungültiger Einstellungswert success: Einstellungen aktualisiert invalid_onboarding_state: Ungültiger Onboarding-Status + invalid_sync_time: Ungültiges Synchronisierungszeitformat. Bitte verwende das Format HH:MM (z. B. 02:30). + scheduler_sync_failed: Einstellungen gespeichert, aber die Synchronisierungsplanung konnte nicht aktualisiert werden. Bitte versuche es erneut oder prüfe die Server-Logs. clear_cache: cache_cleared: Daten-Cache wurde geleert. Dies kann einige Augenblicke dauern. not_authorized: Du bist nicht berechtigt, diese Aktion auszuführen. + sync_settings: + auto_sync_label: Automatische Synchronisierung aktivieren + auto_sync_description: Wenn aktiviert, werden alle Konten täglich zur angegebenen Zeit automatisch synchronisiert. + auto_sync_time_label: Synchronisierungszeit (HH:MM) + auto_sync_time_description: Lege die Tageszeit fest, zu der die automatische Synchronisierung erfolgen soll. + include_pending_label: Ausstehende Transaktionen einbeziehen + include_pending_description: Wenn aktiviert, werden ausstehende (noch nicht gebuchte) Transaktionen importiert und bei Buchung automatisch abgeglichen. Deaktivieren, wenn deine Bank unzuverlässige Ausstehend-Daten liefert. + env_configured_message: Diese Einstellung ist deaktiviert, weil eine Anbieter-Umgebungsvariable (SIMPLEFIN_INCLUDE_PENDING oder PLAID_INCLUDE_PENDING) gesetzt ist. Entferne sie, um diese Einstellung zu aktivieren. diff --git a/config/locales/views/settings/hostings/en.yml b/config/locales/views/settings/hostings/en.yml index 8f3fcec32..814d0b13c 100644 --- a/config/locales/views/settings/hostings/en.yml +++ b/config/locales/views/settings/hostings/en.yml @@ -7,6 +7,9 @@ en: email_confirmation_description: When enabled, users must confirm their email address when changing it. email_confirmation_title: Require email confirmation + default_family_title: Default family for new users + default_family_description: "Put new users on this family/group only if they have no invitation." + default_family_none: None (create new family) generate_tokens: Generate new code generated_tokens: Generated codes title: Onboarding @@ -16,6 +19,7 @@ en: invite_only: Invite-only show: general: General Settings + ai_assistant: AI Assistant financial_data_providers: Financial Data Providers sync_settings: Sync Settings invites: Invite Codes @@ -35,6 +39,32 @@ en: providers: twelve_data: Twelve Data yahoo_finance: Yahoo Finance + assistant_settings: + title: AI Assistant + description: Choose how the chat assistant responds. Builtin uses your configured LLM provider directly. External delegates to a remote AI agent that can call back to Sure's financial tools via MCP. + type_label: Assistant type + type_builtin: Builtin (direct LLM) + type_external: External (remote agent) + external_status: External assistant endpoint + external_configured: Configured + external_not_configured: Not configured. Enter the URL and token below, or set EXTERNAL_ASSISTANT_URL and EXTERNAL_ASSISTANT_TOKEN environment variables. + env_notice: "Assistant type is locked to '%{type}' via ASSISTANT_TYPE environment variable." + env_configured_external: Successfully configured through environment variables. + url_label: Endpoint URL + url_placeholder: "https://your-agent-host/v1/chat" + url_help: The full URL to your agent's API endpoint. Your agent provider will give you this. + token_label: API Token + token_placeholder: Enter the token from your agent provider + token_help: The authentication token provided by your external agent. This is sent as a Bearer token with each request. + agent_id_label: Agent ID (Optional) + agent_id_placeholder: "main (default)" + agent_id_help: Routes to a specific agent when the provider hosts multiple. Leave blank for the default. + disconnect_title: External connection + disconnect_description: Remove the external assistant connection and switch back to the builtin assistant. + disconnect_button: Disconnect + confirm_disconnect: + title: Disconnect external assistant? + body: This will remove the saved URL, token, and agent ID, and switch to the builtin assistant. You can reconnect later by entering new credentials. brand_fetch_settings: description: Enter the Client ID provided by Brand Fetch label: Client ID @@ -83,6 +113,8 @@ en: invalid_onboarding_state: Invalid onboarding state invalid_sync_time: Invalid sync time format. Please use HH:MM format (e.g., 02:30). scheduler_sync_failed: Settings saved, but failed to update the sync schedule. Please try again or check the server logs. + disconnect_external_assistant: + external_assistant_disconnected: External assistant disconnected clear_cache: cache_cleared: Data cache has been cleared. This may take a few moments to complete. not_authorized: You are not authorized to perform this action diff --git a/config/locales/views/settings/hostings/es.yml b/config/locales/views/settings/hostings/es.yml index df51060cd..aff9d71f8 100644 --- a/config/locales/views/settings/hostings/es.yml +++ b/config/locales/views/settings/hostings/es.yml @@ -4,7 +4,7 @@ es: hostings: invite_code_settings: description: Controla cómo se registran nuevas personas en tu instancia de %{product}. - email_confirmation_description: Cuando está habilitado, los usuarios deben confirmar su dirección de correo electrónico al cambiarla. + email_confirmation_description: Cuando está habilitado, los usuarios deben confirmar su dirección de correo electrónico al cambiarla o registrarse. email_confirmation_title: Requerir confirmación de correo electrónico generate_tokens: Generar nuevo código generated_tokens: Códigos generados @@ -15,22 +15,59 @@ es: invite_only: Solo con invitación show: general: Configuración General + ai_assistant: Asistente de IA financial_data_providers: Proveedores de Datos Financieros + sync_settings: Ajustes de Sincronización invites: Códigos de Invitación title: Autoalojamiento danger_zone: Zona de Peligro clear_cache: Limpiar caché de datos - clear_cache_warning: Limpiar la caché de datos eliminará todos los tipos de cambio, precios de valores, - saldos de cuentas y otros datos. Esto no eliminará cuentas, transacciones, categorías u otros datos propiedad del usuario. + clear_cache_warning: Limpiar la caché de datos eliminará todos los tipos de cambio, precios de valores, saldos de cuentas y otros datos temporales. Esto no eliminará cuentas, transacciones, categorías ni otros datos del usuario. confirm_clear_cache: title: ¿Limpiar caché de datos? - body: ¿Estás seguro de que deseas limpiar la caché de datos? Esto eliminará todos los tipos de cambio, - precios de valores, saldos de cuentas y otros datos. Esta acción no se puede deshacer. + body: ¿Estás seguro de que deseas limpiar la caché de datos? Se eliminarán tipos de cambio, precios y saldos históricos. Esta acción no se puede deshacer. + provider_selection: + title: Selección de Proveedores + description: Elige qué servicio usar para obtener tipos de cambio y precios de acciones. Yahoo Finance es gratuito y no requiere clave API. Twelve Data requiere una clave API (gratuita disponible) pero ofrece mayor cobertura de datos. + exchange_rate_provider_label: Proveedor de tipos de cambio + securities_provider_label: Proveedor de valores (Precios de acciones) + env_configured_message: La selección de proveedor está desactivada porque las variables de entorno (EXCHANGE_RATE_PROVIDER o SECURITIES_PROVIDER) están definidas. Para habilitar la selección aquí, elimina dichas variables de tu configuración. + providers: + twelve_data: Twelve Data + yahoo_finance: Yahoo Finance + assistant_settings: + title: Asistente de IA + description: Elige cómo responde el asistente del chat. "Integrado" utiliza directamente tu proveedor de LLM configurado. "Externo" delega en un agente remoto que puede interactuar con las herramientas financieras de Sure mediante MCP. + type_label: Tipo de asistente + type_builtin: Integrado (LLM directo) + type_external: Externo (agente remoto) + external_status: Punto de conexión del asistente externo + external_configured: Configurado + external_not_configured: No configurado. Introduce la URL y el token a continuación, o define las variables de entorno EXTERNAL_ASSISTANT_URL y EXTERNAL_ASSISTANT_TOKEN. + env_notice: "El tipo de asistente está fijado en '%{type}' mediante la variable de entorno ASSISTANT_TYPE." + env_configured_external: Configurado correctamente mediante variables de entorno. + url_label: URL del punto de conexión (Endpoint) + url_placeholder: "https://tu-agente-host/v1/chat" + url_help: La URL completa del punto de conexión de la API de tu agente. Tu proveedor de agentes te facilitará esta dirección. + token_label: Token de API + token_placeholder: Introduce el token de tu proveedor de agentes + token_help: El token de autenticación proporcionado por tu agente externo. Se envía como un token Bearer en cada solicitud. + agent_id_label: ID del Agente (Opcional) + agent_id_placeholder: "main (por defecto)" + agent_id_help: Dirige las peticiones a un agente específico si el proveedor aloja varios. Déjalo en blanco para el predeterminado. + disconnect_title: Conexión externa + disconnect_description: Elimina la conexión del asistente externo y vuelve al asistente integrado. + disconnect_button: Desconectar + confirm_disconnect: + title: ¿Desconectar asistente externo? + body: Esto eliminará la URL guardada, el token y el ID del agente, y cambiará al asistente integrado. Podrás volver a conectarlo más tarde introduciendo nuevas credenciales. brand_fetch_settings: description: Introduce el ID de Cliente proporcionado por Brand Fetch label: ID de Cliente placeholder: Introduce tu ID de Cliente aquí title: Configuración de Brand Fetch + high_res_label: Activar logotipos de alta resolución + high_res_description: Cuando está habilitado, los logotipos se obtendrán a una resolución de 120x120 en lugar de 40x40. Esto ofrece imágenes más nítidas en pantallas de alta densidad de píxeles (DPI). openai_settings: description: Introduce el token de acceso y, opcionalmente, configura un proveedor compatible con OpenAI personalizado env_configured_message: Configurado con éxito a través de variables de entorno. @@ -40,6 +77,12 @@ es: uri_base_placeholder: "https://api.openai.com/v1 (por defecto)" model_label: Modelo (Opcional) model_placeholder: "gpt-4.1 (por defecto)" + json_mode_label: Modo JSON + json_mode_auto: Automático (recomendado) + json_mode_strict: Estricto (mejor para modelos "thinking") + json_mode_none: Ninguno (mejor para modelos estándar) + json_mode_json_object: Objeto JSON + json_mode_help: "El modo Estricto funciona mejor con modelos de razonamiento (qwen-thinking, deepseek-reasoner). El modo Ninguno funciona mejor con modelos estándar (llama, mistral, gpt-oss)." title: OpenAI yahoo_finance_settings: title: Yahoo Finance @@ -55,11 +98,27 @@ es: label: Clave API placeholder: Introduce tu clave API aquí plan: Plan %{plan} + plan_upgrade_warning_title: Algunos activos requieren un plan de pago + plan_upgrade_warning_description: Los siguientes activos de tu cartera no pueden sincronizar precios con tu plan actual de Twelve Data. + requires_plan: requiere el plan %{plan} + view_pricing: Ver precios de Twelve Data title: Twelve Data update: - failure: Valor de configuración inválido + failure: Valor de configuración no válido success: Configuración actualizada - invalid_onboarding_state: Estado de incorporación inválido + invalid_onboarding_state: Estado de incorporación no válido + invalid_sync_time: Formato de hora de sincronización no válido. Por favor, usa el formato HH:MM (ej. 02:30). + scheduler_sync_failed: Ajustes guardados, pero no se pudo actualizar la programación de sincronización. Inténtalo de nuevo o revisa los registros del servidor. + disconnect_external_assistant: + external_assistant_disconnected: Asistente externo desconectado clear_cache: cache_cleared: La caché de datos ha sido limpiada. Esto puede tardar unos momentos en completarse. not_authorized: No estás autorizado para realizar esta acción + sync_settings: + auto_sync_label: Activar sincronización automática + auto_sync_description: Cuando está habilitado, todas las cuentas se sincronizarán automáticamente cada día a la hora especificada. + auto_sync_time_label: Hora de sincronización (HH:MM) + auto_sync_time_description: Especifica la hora del día en la que debe ocurrir la sincronización automática. + include_pending_label: Incluir transacciones pendientes + include_pending_description: Cuando está habilitado, las transacciones pendientes (no liquidadas) se importarán y se conciliarán automáticamente cuando se confirmen. Desactívalo si tu banco proporciona datos pendientes poco fiables. + env_configured_message: Este ajuste está desactivado porque hay una variable de entorno del proveedor (SIMPLEFIN_INCLUDE_PENDING o PLAID_INCLUDE_PENDING) definida. Elimínala para habilitar este ajuste aquí. \ No newline at end of file diff --git a/config/locales/views/settings/hostings/pl.yml b/config/locales/views/settings/hostings/pl.yml new file mode 100644 index 000000000..95b449e35 --- /dev/null +++ b/config/locales/views/settings/hostings/pl.yml @@ -0,0 +1,155 @@ +--- +pl: + settings: + hostings: + invite_code_settings: + description: Kontroluj, jak nowe osoby rejestrują się w Twojej instancji %{product}. + email_confirmation_description: Gdy opcja jest włączona, użytkownicy muszą potwierdzić adres e-mail przy jego zmianie. + email_confirmation_title: Wymagaj potwierdzenia e-mail + default_family_title: Domyślna rodzina dla nowych użytkowników + default_family_description: Dodaj nowych użytkowników do tej rodziny/grupy tylko wtedy, gdy nie mają zaproszenia. + default_family_none: Brak (utwórz nową rodzinę) + generate_tokens: Wygeneruj nowy kod + generated_tokens: Wygenerowane kody + title: Onboarding + states: + open: Otwarty + closed: Zamknięty + invite_only: Tylko na zaproszenie + show: + general: Ustawienia ogólne + ai_assistant: Asystent AI + financial_data_providers: Dostawcy danych finansowych + sync_settings: Ustawienia synchronizacji + invites: Kody zaproszeń + title: Self-Hosting + danger_zone: Strefa ryzyka + clear_cache: Wyczyść pamięć podręczną danych + clear_cache_warning: Wyczyszczenie pamięci podręcznej danych usunie wszystkie kursy walut, ceny papierów wartościowych, salda kont i inne dane. Nie usunie to kont, transakcji, kategorii ani innych danych należących do użytkownika. + confirm_clear_cache: + title: Wyczyścić pamięć podręczną danych? + body: Czy na pewno chcesz wyczyścić pamięć podręczną danych? Spowoduje to usunięcie wszystkich kursów walut, cen papierów wartościowych, sald kont i innych danych. Tej akcji nie można cofnąć. + provider_selection: + title: Wybór dostawcy + description: Wybierz usługę do pobierania kursów walut i cen papierów wartościowych. Yahoo Finance jest darmowe i nie wymaga klucza API. Twelve Data wymaga darmowego klucza API, ale może oferować szersze pokrycie danych. + exchange_rate_provider_label: Dostawca kursów walut + securities_provider_label: Dostawca papierów wartościowych (cen akcji) + env_configured_message: Wybór dostawcy jest wyłączony, ponieważ ustawiono zmienne środowiskowe (EXCHANGE_RATE_PROVIDER lub SECURITIES_PROVIDER). Aby włączyć wybór tutaj, usuń te zmienne z konfiguracji. + providers: + twelve_data: Twelve Data + yahoo_finance: Yahoo Finance + assistant_settings: + title: Asystent AI + description: Wybierz, jak odpowiada asystent czatu. Wbudowany asystent korzysta bezpośrednio ze skonfigurowanego dostawcy LLM. Zewnętrzny deleguje zapytania do zdalnego agenta AI, który może korzystać z narzędzi finansowych Sure przez MCP. + type_label: Typ asystenta + type_builtin: Wbudowany (bezpośredni LLM) + type_external: Zewnętrzny (zdalny agent) + external_status: Endpoint zewnętrznego asystenta + external_configured: Skonfigurowany + external_not_configured: Nieskonfigurowany. Wprowadź poniżej URL i token albo ustaw zmienne środowiskowe EXTERNAL_ASSISTANT_URL i EXTERNAL_ASSISTANT_TOKEN. + env_notice: Typ asystenta jest zablokowany na '%{type}' przez zmienną środowiskową ASSISTANT_TYPE. + env_configured_external: Pomyślnie skonfigurowano przez zmienne środowiskowe. + url_label: URL endpointu + url_placeholder: https://your-agent-host/v1/chat + url_help: Pełny URL endpointu API Twojego agenta. Otrzymasz go od dostawcy agenta. + token_label: Token API + token_placeholder: Wprowadź token od dostawcy agenta + token_help: Token uwierzytelniający dostarczony przez zewnętrznego agenta. Jest wysyłany jako token Bearer z każdym żądaniem. + agent_id_label: ID agenta (opcjonalne) + agent_id_placeholder: main (domyślnie) + agent_id_help: Kieruje do konkretnego agenta, gdy dostawca hostuje ich wiele. Pozostaw puste, aby użyć domyślnego. + disconnect_title: Połączenie zewnętrzne + disconnect_description: Usuń połączenie z zewnętrznym asystentem i przełącz z powrotem na asystenta wbudowanego. + disconnect_button: Rozłącz + confirm_disconnect: + title: Rozłączyć zewnętrznego asystenta? + body: To usunie zapisany URL, token i ID agenta oraz przełączy na asystenta wbudowanego. Możesz połączyć ponownie później, podając nowe dane. + brand_fetch_settings: + description: Wprowadź Client ID otrzymany od Brand Fetch + label: Client ID + placeholder: Wprowadź tutaj swój Client ID + title: Ustawienia Brand Fetch + high_res_label: Włącz logotypy o wysokiej rozdzielczości + high_res_description: Gdy włączone, logotypy będą pobierane w rozdzielczości 120x120 zamiast 40x40. Zapewnia to ostrzejsze obrazy na ekranach o wysokim DPI. + openai_settings: + description: Wprowadź token dostępu i opcjonalnie skonfiguruj niestandardowego dostawcę zgodnego z OpenAI + env_configured_message: Pomyślnie skonfigurowano przez zmienne środowiskowe. + access_token_label: Token dostępu + access_token_placeholder: Wprowadź tutaj token dostępu + uri_base_label: Bazowy URL API (opcjonalne) + uri_base_placeholder: https://api.openai.com/v1 (domyślnie) + model_label: Model (opcjonalne) + model_placeholder: gpt-4.1 (domyślnie) + json_mode_label: Tryb JSON + json_mode_auto: Auto (zalecane) + json_mode_strict: Ścisły (najlepszy dla modeli rozumujących) + json_mode_none: Brak (najlepszy dla modeli standardowych) + json_mode_json_object: Obiekt JSON + json_mode_help: Tryb ścisły najlepiej działa z modelami rozumującymi (qwen-thinking, deepseek-reasoner). Tryb brak najlepiej działa z modelami standardowymi (llama, mistral, gpt-oss). + title: OpenAI + yahoo_finance_settings: + title: Yahoo Finance + description: Yahoo Finance zapewnia bezpłatny dostęp do cen akcji, kursów walut i danych finansowych bez potrzeby klucza API. + status_active: Yahoo Finance jest aktywne i działa + status_inactive: Połączenie z Yahoo Finance nie powiodło się + connection_failed: Nie można połączyć z Yahoo Finance + troubleshooting: Sprawdź połączenie internetowe i ustawienia zapory. Yahoo Finance może być tymczasowo niedostępne. + twelve_data_settings: + api_calls_used: "%{used} / %{limit} wykorzystanych dziennych wywołań API (%{percentage})" + description: Wprowadź klucz API otrzymany od Twelve Data + env_configured_message: Pomyślnie skonfigurowano przez zmienną środowiskową TWELVE_DATA_API_KEY. + label: Klucz API + placeholder: Wprowadź tutaj klucz API + plan: "plan %{plan}" + plan_upgrade_warning_title: Niektóre tickery wymagają płatnego planu + plan_upgrade_warning_description: Poniższych tickerów w Twoim portfelu nie można synchronizować przy Twoim aktualnym planie Twelve Data. + requires_plan: wymaga planu %{plan} + view_pricing: Zobacz cennik Twelve Data + title: Twelve Data + gus_sdp_settings: + title: GUS SDP (Inflacja) + description: Opcjonalny klucz API do danych inflacyjnych GUS SDP. Pozostaw puste, aby korzystać z bezpłatnego poziomu anonimowego. + env_configured_message: Pomyślnie skonfigurowano przez zmienną środowiskową GUS_SDP_API_KEY. + configured_in_settings_message: Klucz API skonfigurowany w ustawieniach. + configured_via_env: Klucz API jest skonfigurowany przez zmienną środowiskową GUS_SDP_API_KEY. + free_default_message: Brak skonfigurowanego klucza API. Domyślnie używany jest bezpłatny dostęp anonimowy. + clear_api_key: Wyczyść zapisany klucz API + clear_api_key_confirm: Usunąć zapisany klucz API GUS? + label: Klucz API + placeholder: Wprowadź klucz API GUS SDP + import_enabled_label: Włącz automatyczny import CPI GUS + import_enabled_help: Domyślnie wyłączone. Włącz tylko, jeśli chcesz automatycznie importować stawki CPI do obliczeń EOD/ROD. + import_enabled_env_locked: To ustawienie jest zablokowane przez zmienną środowiskową GUS_INFLATION_IMPORT_ENABLED. + start_year: Rok początkowy + end_year: Rok końcowy + import_now: Importuj historię CPI teraz + last_import: Ostatni import + last_range: Ostatni zakres + last_count: Zaimportowane rekordy + last_error: Ostatni błąd + stored_records: Zapisane rekordy + stored_range: Zapisany zakres + never: Nigdy + import_gus_inflation_rates: + import_enqueued: Import CPI został dodany do kolejki. + import_disabled: Import CPI jest wyłączony. Najpierw włącz go w ustawieniach. + invalid_import_range: Nieprawidłowy zakres lat dla importu CPI. + update: + failure: Nieprawidłowa wartość ustawienia + success: Ustawienia zostały zaktualizowane + invalid_onboarding_state: Nieprawidłowy stan onboardingu + invalid_sync_time: Nieprawidłowy format czasu synchronizacji. Użyj formatu HH:MM (np. 02:30). + scheduler_sync_failed: Ustawienia zapisano, ale nie udało się zaktualizować harmonogramu synchronizacji. Spróbuj ponownie lub sprawdź logi serwera. + disconnect_external_assistant: + external_assistant_disconnected: Zewnętrzny asystent został rozłączony + clear_cache: + cache_cleared: Pamięć podręczna danych została wyczyszczona. Zakończenie operacji może zająć chwilę. + not_authorized: Nie masz uprawnień do wykonania tej akcji + sync_settings: + auto_sync_label: Włącz automatyczną synchronizację + auto_sync_description: Gdy włączone, wszystkie konta będą automatycznie synchronizowane codziennie o wskazanej godzinie. + auto_sync_time_label: Czas synchronizacji (HH:MM) + auto_sync_time_description: Określ godzinę dnia, o której ma odbywać się automatyczna synchronizacja. + include_pending_label: Uwzględniaj transakcje oczekujące + include_pending_description: Gdy włączone, transakcje oczekujące (niezaksięgowane) będą importowane i automatycznie uzgadniane po zaksięgowaniu. Wyłącz, jeśli Twój bank dostarcza niewiarygodne dane oczekujące. + env_configured_message: To ustawienie jest wyłączone, ponieważ ustawiono zmienną środowiskową dostawcy (SIMPLEFIN_INCLUDE_PENDING lub PLAID_INCLUDE_PENDING). Usuń ją, aby włączyć to ustawienie. diff --git a/config/locales/views/settings/pl.yml b/config/locales/views/settings/pl.yml new file mode 100644 index 000000000..9962be6a7 --- /dev/null +++ b/config/locales/views/settings/pl.yml @@ -0,0 +1,192 @@ +--- +pl: + views: + settings: + payments: + renewal: Twoje wsparcie będzie kontynuowane %{date}. + cancellation: Twoje wsparcie kończy się %{date}. + settings: + ai_prompts: + show: + page_title: Prompty AI + openai_label: OpenAI + disable_ai: Wyłącz asystenta AI + prompt_instructions: Instrukcje promptów + main_system_prompt: + title: Główny prompt systemowy + subtitle: Podstawowe instrukcje definiujące, jak asystent AI zachowuje się we wszystkich rozmowach + transaction_categorizer: + title: Kategoryzator transakcji + subtitle: AI automatycznie kategoryzuje Twoje transakcje na podstawie zdefiniowanych kategorii + merchant_detector: + title: Wykrywanie sprzedawcy + subtitle: AI identyfikuje i wzbogaca dane transakcji o informacje o sprzedawcy + payments: + show: + page_title: Płatności + subscription_subtitle: Zaktualizuj dane swojej karty kredytowej + subscription_title: Zarządzaj wpłatami + appearances: + show: + page_title: Wygląd + theme_title: Motyw + theme_subtitle: Wybierz preferowany motyw aplikacji + theme_dark: Ciemny + theme_light: Jasny + theme_system: Systemowy + transactions_title: Transakcje + transactions_subtitle: Dostosuj sposób wyświetlania transakcji + dashboard_title: Pulpit + dashboard_subtitle: Dostosuj sposób wyświetlania pulpitu + dashboard_two_column_title: Układ dwukolumnowy + dashboard_two_column_description: Wyświetlaj widżety pulpitu w dwóch kolumnach na dużych ekranach. Gdy opcja jest wyłączona, widżety układają się w jednej kolumnie. + split_grouped_title: Grupuj podzielone transakcje + split_grouped_description: Pokazuj podzielone transakcje zgrupowane pod transakcją nadrzędną na liście transakcji. Gdy opcja jest wyłączona, pozycje podrzędne pojawiają się jako osobne wiersze. + preferences: + show: + country: Kraj + currency: Waluta + date_format: Format daty + general_subtitle: Skonfiguruj swoje preferencje + general_title: Ogólne + default_period: Domyślny okres + default_account_order: Domyślna kolejność kont + language: Język + language_auto: Język przeglądarki + page_title: Preferencje + timezone: Strefa czasowa + month_start_day: Miesiąc budżetowy zaczyna się + month_start_day_hint: Ustaw, kiedy zaczyna się Twój miesiąc budżetowy (np. dzień wypłaty) + month_start_day_warning: Twoje budżety i obliczenia MTD będą używać tego niestandardowego dnia startowego zamiast 1. dnia każdego miesiąca. + sharing_title: "Udostępnianie %{moniker}" + sharing_subtitle: Kontroluj, jak konta są udostępniane w Twoim %{moniker} + sharing_default_label: Domyślne udostępnianie dla nowych kont + sharing_shared: Udostępnij wszystkim członkom + sharing_private: Domyślnie ustaw jako prywatne + profiles: + destroy: + cannot_remove_self: Nie możesz usunąć siebie z konta. + member_removal_failed: Wystąpił problem podczas usuwania członka. + member_removed: Członek został pomyślnie usunięty. + not_authorized: Nie masz uprawnień do usuwania członków. + show: + confirm_delete: + body: Czy na pewno chcesz trwale usunąć swoje konto? Tej akcji nie można cofnąć. + title: Usunąć konto? + confirm_reset: + body: Czy na pewno chcesz zresetować swoje konto? Spowoduje to usunięcie wszystkich kont, kategorii, sprzedawców, tagów i innych danych. Tej akcji nie można cofnąć. + title: Zresetować konto? + confirm_reset_with_sample_data: + body: Czy na pewno chcesz zresetować swoje konto i załadować przykładowe dane? Spowoduje to usunięcie istniejących danych i zastąpienie ich danymi demonstracyjnymi, aby bezpiecznie poznawać Sure. + title: Zresetować konto i załadować przykładowe dane? + confirm_remove_invitation: + body: Czy na pewno chcesz usunąć zaproszenie dla %{email}? + title: Usuń zaproszenie + confirm_remove_member: + body: Czy na pewno chcesz usunąć %{name} ze swojego konta? + title: Usuń członka + danger_zone_title: Strefa ryzyka + delete_account: Usuń konto + delete_account_warning: Usunięcie konta trwale usunie wszystkie Twoje dane i nie można tego cofnąć. + reset_account: Zresetuj konto + reset_account_warning: Reset konta usunie wszystkie konta, kategorie, sprzedawców, tagi i inne dane, ale pozostawi Twoje konto użytkownika. + reset_account_with_sample_data: Zresetuj i załaduj dane + reset_account_with_sample_data_warning: Usuń wszystkie istniejące dane i załaduj nowe dane przykładowe, aby poznawać aplikację na gotowym środowisku. + email: E-mail + first_name: Imię + group_form_input_placeholder: Wprowadź nazwę grupy + group_form_label: Nazwa grupy + group_title: Członkowie grupy + household_form_input_placeholder: Wprowadź nazwę gospodarstwa domowego + household_form_label: Nazwa gospodarstwa domowego + household_subtitle: Zaproszone osoby mogą zalogować się do Twojego konta %{moniker} i uzyskać dostęp do współdzielonych zasobów. + household_title: Gospodarstwo domowe + invitation_link: Odnośnik do zaproszenia + invite_member: Dodaj członka + last_name: Nazwisko + page_title: Informacje profilowe + pending: Oczekujące + profile_subtitle: Dostosuj sposób, w jaki wyświetlasz się w %{product_name} + profile_title: Dane osobiste + remove_invitation: Usuń zaproszenie + remove_member: Usuń członka + save: Zapisz + securities: + show: + page_title: Bezpieczeństwo + mfa_title: Uwierzytelnianie dwuskładnikowe + mfa_description: Dodaj dodatkową warstwę bezpieczeństwa do konta, wymagając kodu z aplikacji uwierzytelniającej podczas logowania + enable_mfa: Włącz 2FA + disable_mfa: Wyłącz 2FA + disable_mfa_confirm: Czy na pewno chcesz wyłączyć uwierzytelnianie dwuskładnikowe? + sso_title: Połączone konta + sso_subtitle: Zarządzaj połączeniami kont jednokrotnego logowania + sso_disconnect: Odłącz + sso_last_used: Ostatnio używane + sso_never: Nigdy + sso_no_email: Brak adresu e-mail + sso_no_identities: Brak połączonych kont SSO + sso_connect_hint: Wyloguj się i zaloguj przez dostawcę SSO, aby połączyć konto. + sso_confirm_title: Odłączyć konto? + sso_confirm_body: Czy na pewno chcesz odłączyć konto %{provider}? Możesz je połączyć ponownie później, logując się ponownie przez tego dostawcę. + sso_confirm_button: Odłącz + sso_warning_message: To jest Twoja jedyna metoda logowania. Przed odłączeniem ustaw hasło w ustawieniach bezpieczeństwa, w przeciwnym razie możesz utracić dostęp do konta. + settings_nav: + accounts_label: Konta + advanced_section_title: Zaawansowane + ai_prompts_label: Prompty AI + api_key_label: Klucz API + payment_label: Płatności + categories_label: Kategorie + feedback_label: Opinie + general_section_title: Ogólne + imports_label: Importy + exports_label: Eksporty + logout: Wyloguj + merchants_label: Sprzedawcy + guides_label: Przewodniki + other_section_title: Więcej + preferences_label: Preferencje + profile_label: Informacje profilowe + recurring_transactions_label: Cykliczne + rules_label: Reguły + security_label: Bezpieczeństwo + self_hosting_label: Hosting własny + tags_label: Tagi + transactions_section_title: Transakcje + whats_new_label: Co nowego + api_keys_label: Klucz API + appearance_label: Wygląd + bank_sync_label: Synchronizacja banku + settings_nav_link_large: + next: Dalej + previous: Wstecz + user_avatar_field: + accepted_formats: JPG lub PNG. Maks. 5 MB. + choose: Prześlij zdjęcie + choose_label: "(opcjonalne)" + change: Zmień zdjęcie + providers: + show: + coinbase_title: Coinbase + encryption_error: + title: Wymagana konfiguracja szyfrowania + message: Klucze szyfrowania Active Record nie są skonfigurowane. Upewnij się, że dane uwierzytelniające szyfrowania (active_record_encryption.primary_key, active_record_encryption.deterministic_key oraz active_record_encryption.key_derivation_salt) są poprawnie ustawione w poświadczeniach Rails lub zmiennych środowiskowych przed użyciem dostawców synchronizacji. + coinbase_panel: + setup_instructions: 'Aby połączyć Coinbase:' + step1_html: Przejdź do Ustawień API Coinbase + step2: Utwórz nowy klucz API z uprawnieniami tylko do odczytu (podgląd kont, podgląd transakcji) + step3: Skopiuj klucz API i sekret API, a następnie wklej je poniżej + api_key_label: Klucz API + api_key_placeholder: Wprowadź klucz API Coinbase + api_secret_label: Sekret API + api_secret_placeholder: Wprowadź sekret API Coinbase + connect_button: Połącz Coinbase + syncing: Synchronizacja... + sync: Synchronizuj + disconnect_confirm: Czy na pewno chcesz odłączyć to połączenie Coinbase? Twoje zsynchronizowane konta staną się kontami ręcznymi. + status_connected: Coinbase jest połączony i synchronizuje Twoje zasoby kryptowalutowe. + status_not_connected: Brak połączenia. Wprowadź powyżej dane API, aby rozpocząć. + enable_banking_panel: + callback_url_instruction: Dla URL callback użyj %{callback_url}. + connection_error: Błąd połączenia diff --git a/config/locales/views/settings/pt-BR.yml b/config/locales/views/settings/pt-BR.yml index cc393360a..26fa9fa13 100644 --- a/config/locales/views/settings/pt-BR.yml +++ b/config/locales/views/settings/pt-BR.yml @@ -4,6 +4,7 @@ pt-BR: settings: payments: renewal: "Sua contribuição continua em %{date}." + cancellation: "Sua contribuição termina em %{date}." settings: ai_prompts: show: @@ -25,6 +26,22 @@ pt-BR: page_title: Pagamento subscription_subtitle: Atualize sua assinatura e detalhes de pagamento subscription_title: Gerenciar assinatura + appearances: + show: + page_title: Aparência + theme_title: Tema + theme_subtitle: Escolha um tema preferido para o aplicativo + theme_dark: Escuro + theme_light: Claro + theme_system: Sistema + transactions_title: Transações + transactions_subtitle: Personalize como as transações são exibidas + dashboard_title: Painel + dashboard_subtitle: Personalize como o painel é exibido + dashboard_two_column_title: Layout de duas colunas + dashboard_two_column_description: Exibe os widgets do painel em duas colunas em telas grandes. Quando desativado, os widgets ficam empilhados em uma única coluna. + split_grouped_title: Agrupar transações divididas + split_grouped_description: Mostra as transações divididas agrupadas sob o registro principal na lista de transações. Quando desativado, as subdivisões aparecem como linhas individuais. preferences: show: country: País @@ -33,15 +50,19 @@ pt-BR: general_subtitle: Configure suas preferências general_title: Geral default_period: Período Padrão + default_account_order: Ordem de conta padrão language: Idioma language_auto: Idioma do navegador page_title: Preferências - theme_dark: Escuro - theme_light: Claro - theme_subtitle: Escolha um tema preferido para o aplicativo - theme_system: Sistema - theme_title: Tema timezone: Fuso horário + month_start_day: O mês do orçamento começa em + month_start_day_hint: Defina quando começa o seu mês de orçamento (por exemplo, no dia do pagamento). + month_start_day_warning: Seus orçamentos e cálculos MTD usarão essa data de início personalizada em vez do dia 1º de cada mês. + sharing_title: "Compartilhamento %{moniker}" + sharing_subtitle: "Controle como as contas são compartilhadas no seu %{moniker}" + sharing_default_label: Compartilhamento padrão para novas contas + sharing_shared: Compartilhar com todos os membros + sharing_private: Manter privado por padrão profiles: destroy: cannot_remove_self: Você não pode se remover da conta. @@ -56,6 +77,9 @@ pt-BR: confirm_reset: body: Tem certeza de que deseja resetar sua conta? Isso excluirá todas as suas contas, categorias, comerciantes, tags e outros dados. Esta ação não pode ser desfeita. title: Resetar conta? + confirm_reset_with_sample_data: + body: Tem certeza de que deseja redefinir sua conta e carregar dados de exemplo? Isso excluirá seus dados existentes e os substituirá por dados de demonstração para que você possa explorar o Sure com segurança. + title: Redefinir conta e carregar dados de exemplo? confirm_remove_invitation: body: Tem certeza de que deseja remover o convite para %{email}? title: Remover Convite @@ -68,12 +92,16 @@ pt-BR: os seus dados e não pode ser desfeito. reset_account: Resetar conta reset_account_warning: Resetar sua conta excluirá todas as suas contas, categorias, comerciantes, tags e outros dados, mas manterá sua conta de usuário intacta. + reset_account_with_sample_data: Reiniciar e pré-carregar + reset_account_with_sample_data_warning: Exclua todos os seus dados existentes e, em seguida, carregue novos dados de amostra para que você possa explorar um ambiente pré-configurado. email: E-mail first_name: Primeiro Nome + group_form_input_placeholder: Digite o nome do grupo + group_form_label: Nome do grupo + group_title: Membros do grupo household_form_input_placeholder: Digite o nome da família household_form_label: Nome da família - household_subtitle: Convide membros da família, parceiros e outros indivíduos. Os convidados - podem fazer login na sua família e acessar suas contas compartilhadas. + household_subtitle: Os convidados podem entrar na sua conta %{moniker} e acessar os recursos compartilhados. household_title: Família invitation_link: Link de convite invite_member: Adicionar membro @@ -88,8 +116,27 @@ pt-BR: securities: show: page_title: Segurança + mfa_title: Autenticação de Dois Fatores + mfa_description: Adicione uma camada extra de segurança à sua conta, exigindo um código do seu aplicativo autenticador ao fazer login. + enable_mfa: Ativar 2FA + disable_mfa: Desativar 2FA + disable_mfa_confirm: Tem certeza de que deseja desativar a autenticação de dois fatores? + sso_title: Contas Conectadas + sso_subtitle: Gerencie suas conexões de contas de login único (SSO) + sso_disconnect: Desconectar + sso_last_used: Último uso + sso_never: Nunca + sso_no_email: Sem e-mail + sso_no_identities: Nenhuma conta SSO conectada + sso_connect_hint: Saia da sua conta e entre novamente com um provedor SSO para conectar uma conta. + sso_confirm_title: Desconectar Conta? + sso_confirm_body: Tem certeza de que deseja desconectar sua conta %{provider}? Você poderá reconectá-la posteriormente fazendo login novamente com esse provedor. + sso_confirm_button: Desconectar + sso_warning_message: Este é o seu único método de login. Você deve definir uma senha nas suas configurações de segurança antes de desconectar, caso contrário, você poderá ficar bloqueado na sua conta. settings_nav: accounts_label: Contas + advanced_section_title: Avançado + ai_prompts_label: Sugestões de IA api_key_label: Chave da API payment_label: Pagamento categories_label: Categorias @@ -99,15 +146,19 @@ pt-BR: exports_label: Exportações logout: Sair merchants_label: Comerciantes + guides_label: Guias other_section_title: Mais preferences_label: Preferências profile_label: Conta + recurring_transactions_label: Recorrente rules_label: Regras security_label: Segurança self_hosting_label: Auto hospedagem tags_label: Tags transactions_section_title: Transações whats_new_label: Novidades + api_keys_label: Chave da API + bank_sync_label: Sincronização bancária settings_nav_link_large: next: Próximo previous: Voltar @@ -116,3 +167,27 @@ pt-BR: choose: Enviar foto choose_label: (opcional) change: Alterar foto + providers: + show: + coinbase_title: Coinbase + encryption_error: + title: Configuração de criptografia necessária + message: As chaves de criptografia do Active Record não estão configuradas. Certifique-se de que as credenciais de criptografia (active_record_encryption.primary_key, active_record_encryption.deterministic_key e active_record_encryption.key_derivation_salt) estejam configuradas corretamente em suas credenciais do Rails ou variáveis ​​de ambiente antes de usar provedores de sincronização. + coinbase_panel: + setup_instructions: "Para conectar à Coinbase:" + step1_html: Acesse Configurações da API da Coinbase + step2: Crie uma nova chave de API com permissões somente leitura (visualizar contas, visualizar transações) + step3: Copie sua chave de API e o segredo da API e cole-os abaixo + api_key_label: Chave de API + api_key_placeholder: Insira sua chave de API da Coinbase + api_secret_label: Segredo da API + api_secret_placeholder: Insira seu segredo da API da Coinbase + connect_button: Conectar à Coinbase + syncing: Sincronizando... + sync: Sincronizar + disconnect_confirm: Tem certeza de que deseja desconectar esta conexão com a Coinbase? Suas contas sincronizadas se tornarão contas manuais. + status_connected: A Coinbase está conectada e sincronizando seus ativos em criptomoedas. + status_not_connected: Não conectado. Insira suas credenciais de API acima para começar. + enable_banking_panel: + callback_url_instruction: "Para a URL de retorno de chamada, use %{callback_url}." + connection_error: Erro de conexão diff --git a/config/locales/views/settings/securities/pl.yml b/config/locales/views/settings/securities/pl.yml new file mode 100644 index 000000000..57ff230a2 --- /dev/null +++ b/config/locales/views/settings/securities/pl.yml @@ -0,0 +1,10 @@ +--- +pl: + settings: + securities: + show: + disable_mfa: Wyłącz 2FA + disable_mfa_confirm: Czy na pewno chcesz wyłączyć uwierzytelnianie dwuskładnikowe? To obniży poziom bezpieczeństwa konta. + enable_mfa: Włącz 2FA + mfa_description: Dodaj dodatkową warstwę bezpieczeństwa, wymagając kodu z aplikacji uwierzytelniającej podczas logowania + mfa_title: Uwierzytelnianie dwuskładnikowe diff --git a/config/locales/views/settings/sso_identities/de.yml b/config/locales/views/settings/sso_identities/de.yml new file mode 100644 index 000000000..2df58d70a --- /dev/null +++ b/config/locales/views/settings/sso_identities/de.yml @@ -0,0 +1,7 @@ +--- +de: + settings: + sso_identities: + destroy: + cannot_unlink_last: Die letzte Identität kann nicht getrennt werden + success: Erfolg diff --git a/config/locales/views/settings/sso_identities/es.yml b/config/locales/views/settings/sso_identities/es.yml new file mode 100644 index 000000000..a1edda19f --- /dev/null +++ b/config/locales/views/settings/sso_identities/es.yml @@ -0,0 +1,7 @@ +--- +es: + settings: + sso_identities: + destroy: + cannot_unlink_last: No se puede desvincular la última identidad + success: Éxito \ No newline at end of file diff --git a/config/locales/views/settings/sso_identities/pl.yml b/config/locales/views/settings/sso_identities/pl.yml new file mode 100644 index 000000000..ddc23496a --- /dev/null +++ b/config/locales/views/settings/sso_identities/pl.yml @@ -0,0 +1,7 @@ +--- +pl: + settings: + sso_identities: + destroy: + cannot_unlink_last: Nie można odłączyć ostatniej tożsamości + success: Tożsamość została odłączona diff --git a/config/locales/views/shared/en.yml b/config/locales/views/shared/en.yml index 37e22cd34..f94797c77 100644 --- a/config/locales/views/shared/en.yml +++ b/config/locales/views/shared/en.yml @@ -10,5 +10,6 @@ en: label: Amount syncing_notice: syncing: Syncing accounts data... + require_admin: "Only admins can perform this action" trend_change: no_change: "no change" diff --git a/config/locales/views/shared/pl.yml b/config/locales/views/shared/pl.yml new file mode 100644 index 000000000..03b5a73bf --- /dev/null +++ b/config/locales/views/shared/pl.yml @@ -0,0 +1,15 @@ +--- +pl: + shared: + confirm_modal: + accept: Potwierdź + body_html: "

    Nie będzie można cofnąć tej decyzji

    " + cancel: Anuluj + title: Czy na pewno? + money_field: + label: Kwota + syncing_notice: + syncing: Synchronizowanie danych kont... + require_admin: "Tę akcję mogą wykonać tylko administratorzy" + trend_change: + no_change: "bez zmian" diff --git a/config/locales/views/simplefin_items/de.yml b/config/locales/views/simplefin_items/de.yml index 9e6201489..105640fb8 100644 --- a/config/locales/views/simplefin_items/de.yml +++ b/config/locales/views/simplefin_items/de.yml @@ -30,8 +30,27 @@ de: label: "SimpleFin-Setup-Token:" placeholder: "Füge hier dein SimpleFin-Setup-Token ein..." help_text: "Das Token sollte eine lange Zeichenfolge aus Buchstaben und Zahlen sein." + setup_accounts: + stale_accounts: + title: "Konten nicht mehr in SimpleFIN" + description: "Diese Konten existieren in deiner Datenbank, werden aber nicht mehr von SimpleFIN bereitgestellt. Das kann passieren, wenn sich Kontokonfigurationen beim Anbieter ändern." + action_prompt: "Was möchtest du tun?" + action_delete: "Konto und alle Transaktionen löschen" + action_move: "Transaktionen verschieben nach:" + action_skip: "Vorerst überspringen" + transaction_count: + one: "%{count} Transaktion" + other: "%{count} Transaktionen" complete_account_setup: - success: SimpleFin-Konten wurden erfolgreich eingerichtet! Deine Transaktionen und Positionen werden im Hintergrund importiert. + all_skipped: "Alle Konten wurden übersprungen. Es wurden keine Konten erstellt." + no_accounts: "Keine Konten zum Einrichten." + success: + one: "Ein SimpleFIN-Konto erfolgreich erstellt! Deine Transaktionen und Positionen werden im Hintergrund importiert." + other: "%{count} SimpleFIN-Konten erfolgreich erstellt! Deine Transaktionen und Positionen werden im Hintergrund importiert." + stale_accounts_processed: "Veraltete Konten: %{deleted} gelöscht, %{moved} verschoben." + stale_accounts_errors: + one: "%{count} Aktion für veraltetes Konto fehlgeschlagen. Details in den Logs prüfen." + other: "%{count} Aktionen für veraltete Konten fehlgeschlagen. Details in den Logs prüfen." simplefin_item: add_new: Neue Verbindung hinzufügen confirm_accept: Verbindung löschen @@ -46,8 +65,43 @@ de: setup_needed: Neue Konten bereit zur Einrichtung setup_description: Wähle die Kontotypen für deine neu importierten SimpleFin-Konten aus. setup_action: Neue Konten einrichten + setup_accounts_menu: Konten einrichten + more_accounts_available: + one: "%{count} weiteres Konto kann eingerichtet werden" + other: "%{count} weitere Konten können eingerichtet werden" + accounts_skipped_tooltip: "Einige Konten wurden aufgrund von Fehlern bei der Synchronisierung übersprungen" + accounts_skipped_label: "Übersprungen: %{count}" + rate_limited_ago: "Ratenbegrenzung (vor %{time})" + rate_limited_recently: "Kürzlich ratenbegrenzt" status: Zuletzt vor %{timestamp} synchronisiert status_never: Noch nie synchronisiert status_with_summary: "Zuletzt vor %{timestamp} synchronisiert • %{summary}" syncing: Wird synchronisiert... update: Verbindung aktualisieren + stale_pending_note: "(von Budgets ausgeschlossen)" + stale_pending_accounts: "in: %{accounts}" + reconciled_details_note: "(Details siehe Synchronisierungszusammenfassung)" + duplicate_accounts_skipped: "Einige Konten wurden als Duplikate übersprungen — nutze „Bestehendes Konto verknüpfen“, um sie zusammenzuführen." + select_existing_account: + title: "%{account_name} mit SimpleFIN verknüpfen" + description: Wähle ein SimpleFIN-Konto aus, das mit deinem bestehenden Konto verknüpft werden soll + cancel: Abbrechen + link_account: Konto verknüpfen + no_accounts_found: "Keine SimpleFIN-Konten für diesen %{moniker} gefunden." + wait_for_sync: Wenn du gerade verbunden oder synchronisiert hast, versuche es nach Abschluss der Synchronisierung erneut. + unlink_to_move: Um eine Verknüpfung zu verschieben, trenne sie zuerst im Aktionsmenü des Kontos. + all_accounts_already_linked: Alle SimpleFIN-Konten scheinen bereits verknüpft zu sein. + currently_linked_to: "Aktuell verknüpft mit: %{account_name}" + link_existing_account: + success: Konto erfolgreich mit SimpleFIN verknüpft + errors: + only_manual: Nur manuelle Konten können verknüpft werden + invalid_simplefin_account: Ungültiges SimpleFIN-Konto ausgewählt + reconciled_status: + message: + one: "%{count} doppelte ausstehende Transaktion abgeglichen" + other: "%{count} doppelte ausstehende Transaktionen abgeglichen" + stale_pending_status: + message: + one: "%{count} ausstehende Transaktion älter als %{days} Tage" + other: "%{count} ausstehende Transaktionen älter als %{days} Tage" diff --git a/config/locales/views/simplefin_items/es.yml b/config/locales/views/simplefin_items/es.yml index e36d3db59..f1755c82b 100644 --- a/config/locales/views/simplefin_items/es.yml +++ b/config/locales/views/simplefin_items/es.yml @@ -2,52 +2,106 @@ es: simplefin_items: new: - title: Conectar SimpleFin + title: Conectar SimpleFIN setup_token: Token de configuración - setup_token_placeholder: pega tu token de configuración de SimpleFin + setup_token_placeholder: pega tu token de configuración de SimpleFIN connect: Conectar cancel: Cancelar create: - success: ¡Conexión SimpleFin añadida con éxito! Tus cuentas aparecerán en breve mientras se sincronizan en segundo plano. + success: ¡Conexión SimpleFIN añadida con éxito! Tus cuentas aparecerán en breve mientras se sincronizan en segundo plano. errors: - blank_token: Por favor, introduce un token de configuración de SimpleFin. - invalid_token: Token de configuración inválido. Por favor, verifica que has copiado el token completo desde SimpleFin Bridge. - token_compromised: El token de configuración puede estar comprometido, expirado o ya utilizado. Por favor, crea uno nuevo. + blank_token: Por favor, introduce un token de configuración de SimpleFIN. + invalid_token: Token de configuración no válido. Por favor, verifica que has copiado el token completo desde SimpleFIN Bridge. + token_compromised: El token de configuración puede estar comprometido, caducado o ya utilizado. Por favor, crea uno nuevo. create_failed: "No se pudo conectar: %{message}" - unexpected: Ocurrió un error inesperado. Por favor, inténtalo de nuevo o contacta con soporte. + unexpected: Ha ocurrido un error inesperado. Por favor, inténtalo de nuevo. destroy: - success: La conexión SimpleFin será eliminada. + success: La conexión SimpleFIN será eliminada. update: - success: ¡Conexión SimpleFin actualizada con éxito! Tus cuentas están siendo reconectadas. + success: ¡Conexión SimpleFIN actualizada con éxito! Tus cuentas se están reconectando. errors: - blank_token: Por favor, introduce un token de configuración de SimpleFin. - invalid_token: Token de configuración inválido. Por favor, verifica que has copiado el token completo desde SimpleFin Bridge. - token_compromised: El token de configuración puede estar comprometido, expirado o ya utilizado. Por favor, crea uno nuevo. + blank_token: Por favor, introduce un token de configuración de SimpleFIN. + invalid_token: Token de configuración no válido. Por favor, verifica que has copiado el token completo desde SimpleFIN Bridge. + token_compromised: El token de configuración puede estar comprometido, caducado o ya utilizado. Por favor, crea uno nuevo. update_failed: "No se pudo actualizar la conexión: %{message}" - unexpected: Ocurrió un error inesperado. Por favor, inténtalo de nuevo o contacta con soporte. + unexpected: Ha ocurrido un error inesperado. Por favor, inténtalo de nuevo. edit: setup_token: - label: "Token de configuración de SimpleFin:" - placeholder: "Pega aquí tu token de configuración de SimpleFin..." - help_text: "El token debería ser una cadena larga que comienza con letras y números." + label: "Token de configuración de SimpleFIN:" + placeholder: "Pega aquí tu token de configuración de SimpleFIN..." + help_text: "El token debería ser una cadena larga que comienza con letras y números" + setup_accounts: + stale_accounts: + title: "Cuentas que ya no están en SimpleFIN" + description: "Estas cuentas existen en tu base de datos pero SimpleFIN ya no las proporciona. Esto puede ocurrir cuando cambian las configuraciones de origen." + action_prompt: "¿Qué te gustaría hacer?" + action_delete: "Eliminar cuenta y todas las transacciones" + action_move: "Mover transacciones a:" + action_skip: "Omitir por ahora" + transaction_count: + one: "%{count} transacción" + other: "%{count} transacciones" complete_account_setup: - success: ¡Las cuentas de SimpleFin se han configurado con éxito! Tus transacciones y activos se están importando en segundo plano. + all_skipped: "Se han omitido todas las cuentas. No se ha creado ninguna." + no_accounts: "No hay cuentas para configurar." + success: + one: "¡Se ha creado correctamente %{count} cuenta de SimpleFIN! Tus transacciones y posiciones se están importando en segundo plano." + other: "¡Se han creado correctamente %{count} cuentas de SimpleFIN! Tus transacciones y posiciones se están importando en segundo plano." + stale_accounts_processed: "Cuentas obsoletas: %{deleted} eliminadas, %{moved} movidas." + stale_accounts_errors: + one: "Error en la acción de %{count} cuenta obsoleta. Revisa los registros para más detalles." + other: "Error en las acciones de %{count} cuentas obsoletas. Revisa los registros para más detalles." simplefin_item: add_new: Añadir nueva conexión confirm_accept: Eliminar conexión confirm_body: Esto eliminará permanentemente todas las cuentas de este grupo y todos los datos asociados. - confirm_title: ¿Eliminar conexión SimpleFin? + confirm_title: ¿Eliminar conexión SimpleFIN? delete: Eliminar - deletion_in_progress: "(eliminación en progreso...)" - error: Ocurrió un error al sincronizar los datos + deletion_in_progress: "(eliminación en curso...)" + error: Ha ocurrido un error al sincronizar los datos no_accounts_description: Esta conexión aún no tiene cuentas sincronizadas. no_accounts_title: No se encontraron cuentas - requires_update: Requiere reautenticación + requires_update: Reconectar setup_needed: Nuevas cuentas listas para configurar - setup_description: Elige los tipos de cuenta para tus nuevas cuentas importadas de SimpleFin. + setup_description: Elige los tipos de cuenta para tus nuevas cuentas importadas de SimpleFIN. setup_action: Configurar nuevas cuentas + setup_accounts_menu: Configurar cuentas + more_accounts_available: + one: "Hay %{count} cuenta más disponible para configurar" + other: "Hay %{count} cuentas más disponibles para configurar" + accounts_skipped_tooltip: "Se omitieron algunas cuentas debido a errores durante la sincronización" + accounts_skipped_label: "Omitidas: %{count}" + rate_limited_ago: "Límite de frecuencia alcanzado (hace %{time})" + rate_limited_recently: "Límite de frecuencia alcanzado recientemente" status: Última sincronización hace %{timestamp} status_never: Nunca sincronizado status_with_summary: "Última sincronización hace %{timestamp} • %{summary}" syncing: Sincronizando... - update: Actualizar conexión \ No newline at end of file + update: Actualizar + stale_pending_note: "(excluido de presupuestos)" + stale_pending_accounts: "en: %{accounts}" + reconciled_details_note: "(ver resumen de sincronización para detalles)" + duplicate_accounts_skipped: "Se omitieron algunas cuentas por estar duplicadas — usa 'Vincular cuentas existentes' para fusionarlas." + select_existing_account: + title: "Vincular %{account_name} a SimpleFIN" + description: Selecciona una cuenta de SimpleFIN para vincularla a tu cuenta existente + cancel: Cancelar + link_account: Vincular cuenta + no_accounts_found: "No se han encontrado cuentas de SimpleFIN para este %{moniker}." + wait_for_sync: Si acabas de conectar o sincronizar, inténtalo de nuevo cuando finalice la sincronización. + unlink_to_move: Para mover un vínculo, primero desvincúlalo desde el menú de acciones de la cuenta. + all_accounts_already_linked: Todas las cuentas de SimpleFIN parecen estar ya vinculadas. + currently_linked_to: "Vinculada actualmente a: %{account_name}" + link_existing_account: + success: Cuenta vinculada correctamente a SimpleFIN + errors: + only_manual: Solo se pueden vincular cuentas manuales + invalid_simplefin_account: Se ha seleccionado una cuenta de SimpleFIN no válida + reconciled_status: + message: + one: "%{count} transacción pendiente duplicada conciliada" + other: "%{count} transacciones pendientes duplicadas conciliadas" + stale_pending_status: + message: + one: "%{count} transacción pendiente con más de %{days} días" + other: "%{count} transacciones pendientes con más de %{days} días" \ No newline at end of file diff --git a/config/locales/views/simplefin_items/pl.yml b/config/locales/views/simplefin_items/pl.yml new file mode 100644 index 000000000..6a54341d3 --- /dev/null +++ b/config/locales/views/simplefin_items/pl.yml @@ -0,0 +1,119 @@ +--- +pl: + simplefin_items: + new: + title: Połącz SimpleFIN + setup_token: Token konfiguracji + setup_token_placeholder: wklej token konfiguracji SimpleFIN + connect: Połącz + cancel: Anuluj + create: + success: Połączenie SimpleFIN zostało pomyślnie dodane! Twoje konta pojawią się wkrótce po synchronizacji w tle. + errors: + blank_token: Wprowadź token konfiguracji SimpleFIN. + invalid_token: Nieprawidłowy token konfiguracji. Sprawdź, czy skopiowano pełny token z SimpleFIN Bridge. + token_compromised: Token konfiguracji może być naruszony, wygasły albo już użyty. Utwórz nowy. + create_failed: 'Nie udało się połączyć: %{message}' + unexpected: Wystąpił nieoczekiwany błąd. Spróbuj ponownie. + destroy: + success: Połączenie SimpleFIN zostanie usunięte + update: + success: Połączenie SimpleFIN zostało pomyślnie zaktualizowane! Trwa ponowne łączenie kont. + errors: + blank_token: Wprowadź token konfiguracji SimpleFIN. + invalid_token: Nieprawidłowy token konfiguracji. Sprawdź, czy skopiowano pełny token z SimpleFIN Bridge. + token_compromised: Token konfiguracji może być naruszony, wygasły albo już użyty. Utwórz nowy. + update_failed: 'Nie udało się zaktualizować połączenia: %{message}' + unexpected: Wystąpił nieoczekiwany błąd. Spróbuj ponownie. + edit: + setup_token: + label: 'Token konfiguracji SimpleFIN:' + placeholder: Wklej tutaj token konfiguracji SimpleFIN... + help_text: Token powinien być długim ciągiem znaków zaczynającym się od liter i cyfr + setup_accounts: + stale_accounts: + title: Konta, których nie ma już w SimpleFIN + description: Te konta istnieją w bazie danych, ale nie są już dostarczane przez SimpleFIN. Może się to zdarzyć przy zmianie konfiguracji kont po stronie dostawcy. + action_prompt: Co chcesz zrobić? + action_delete: Usuń konto i wszystkie transakcje + action_move: 'Przenieś transakcje do:' + action_skip: Pomiń na razie + transaction_count: + one: "%{count} transakcja" + few: "%{count} transakcje" + many: "%{count} transakcji" + other: "%{count} transakcji" + complete_account_setup: + all_skipped: Wszystkie konta zostały pominięte. Nie utworzono żadnych kont. + no_accounts: Brak kont do skonfigurowania. + success: + one: Pomyślnie utworzono %{count} konto SimpleFIN! Twoje transakcje i pozycje są importowane w tle. + few: Pomyślnie utworzono %{count} konta SimpleFIN! Twoje transakcje i pozycje są importowane w tle. + many: Pomyślnie utworzono %{count} kont SimpleFIN! Twoje transakcje i pozycje są importowane w tle. + other: Pomyślnie utworzono %{count} kont SimpleFIN! Twoje transakcje i pozycje są importowane w tle. + stale_accounts_processed: 'Przestarzałe konta: %{deleted} usuniętych, %{moved} przeniesionych.' + stale_accounts_errors: + one: "%{count} akcja dla przestarzałego konta nie powiodła się. Sprawdź logi." + few: "%{count} akcje dla przestarzałych kont nie powiodły się. Sprawdź logi." + many: "%{count} akcji dla przestarzałych kont nie powiodło się. Sprawdź logi." + other: "%{count} akcji dla przestarzałych kont nie powiodło się. Sprawdź logi." + simplefin_item: + add_new: Dodaj nowe połączenie + confirm_accept: Usuń połączenie + confirm_body: Spowoduje to trwałe usunięcie wszystkich kont w tej grupie i wszystkich powiązanych danych. + confirm_title: Usunąć połączenie SimpleFIN? + delete: Usuń + deletion_in_progress: "(usuwanie w toku...)" + error: Wystąpił błąd podczas synchronizacji danych + no_accounts_description: To połączenie nie ma jeszcze żadnych zsynchronizowanych kont. + no_accounts_title: Nie znaleziono kont + requires_update: Połącz ponownie + setup_needed: Nowe konta gotowe do konfiguracji + setup_description: Wybierz typy kont dla nowo zaimportowanych kont SimpleFIN. + setup_action: Skonfiguruj nowe konta + setup_accounts_menu: Skonfiguruj konta + more_accounts_available: + one: "%{count} dodatkowe konto do skonfigurowania" + few: "%{count} dodatkowe konta do skonfigurowania" + many: "%{count} dodatkowych kont do skonfigurowania" + other: "%{count} dodatkowych kont do skonfigurowania" + accounts_skipped_tooltip: Niektóre konta zostały pominięte z powodu błędów podczas synchronizacji + accounts_skipped_label: 'Pominięte: %{count}' + rate_limited_ago: Ograniczenie limitu (%{time} temu) + rate_limited_recently: Ostatnio ograniczono limitem + status: Ostatnia synchronizacja %{timestamp} temu + status_never: Nigdy nie synchronizowano + status_with_summary: Ostatnia synchronizacja %{timestamp} temu • %{summary} + syncing: Synchronizacja... + update: Aktualizuj + stale_pending_note: "(wykluczone z budżetów)" + stale_pending_accounts: 'w: %{accounts}' + reconciled_details_note: "(szczegóły w podsumowaniu synchronizacji)" + duplicate_accounts_skipped: Niektóre konta zostały pominięte jako duplikaty — użyj opcji „Połącz istniejące konta”, aby je scalić. + select_existing_account: + title: Połącz %{account_name} z SimpleFIN + description: Wybierz konto SimpleFIN do połączenia z istniejącym kontem + cancel: Anuluj + link_account: Połącz konto + no_accounts_found: Nie znaleziono kont SimpleFIN dla tej/tego %{moniker}. + wait_for_sync: Jeśli właśnie połączyłeś lub zsynchronizowałeś, spróbuj ponownie po zakończeniu synchronizacji. + unlink_to_move: Aby przenieść połączenie, najpierw odłącz je z menu akcji konta. + all_accounts_already_linked: Wszystkie konta SimpleFIN są już połączone. + currently_linked_to: 'Aktualnie połączone z: %{account_name}' + link_existing_account: + success: Konto zostało pomyślnie połączone z SimpleFIN + errors: + only_manual: Można połączyć tylko konta manualne + invalid_simplefin_account: Wybrano nieprawidłowe konto SimpleFIN + reconciled_status: + message: + one: "%{count} oczekująca zduplikowana transakcja została uzgodniona" + few: "%{count} oczekujące zduplikowane transakcje zostały uzgodnione" + many: "%{count} oczekujących zduplikowanych transakcji zostało uzgodnionych" + other: "%{count} oczekujących zduplikowanych transakcji zostało uzgodnionych" + stale_pending_status: + message: + one: "%{count} oczekująca transakcja starsza niż %{days} dni" + few: "%{count} oczekujące transakcje starsze niż %{days} dni" + many: "%{count} oczekujących transakcji starszych niż %{days} dni" + other: "%{count} oczekujących transakcji starszych niż %{days} dni" diff --git a/config/locales/views/snaptrade_items/de.yml b/config/locales/views/snaptrade_items/de.yml new file mode 100644 index 000000000..c0f0cd22c --- /dev/null +++ b/config/locales/views/snaptrade_items/de.yml @@ -0,0 +1,190 @@ +--- +de: + snaptrade_items: + default_name: "SnapTrade-Verbindung" + create: + success: "SnapTrade wurde erfolgreich eingerichtet." + update: + success: "SnapTrade-Konfiguration wurde erfolgreich aktualisiert." + destroy: + success: "SnapTrade-Verbindung wurde zur Löschung vorgemerkt." + connect: + decryption_failed: "SnapTrade-Zugangsdaten konnten nicht gelesen werden. Bitte löschen Sie die Verbindung und legen Sie sie neu an." + connection_failed: "Verbindung zu SnapTrade fehlgeschlagen: %{message}" + callback: + success: "Broker verbunden! Bitte wählen Sie die zu verknüpfenden Konten." + no_item: "SnapTrade-Konfiguration nicht gefunden." + complete_account_setup: + success: + one: "%{count} Konto erfolgreich verknüpft." + other: "%{count} Konten erfolgreich verknüpft." + partial_success: + one: "%{count} Konto verknüpft. %{failed_count} Verknüpfung(en) fehlgeschlagen." + other: "%{count} Konten verknüpft. %{failed_count} Verknüpfung(en) fehlgeschlagen." + link_failed: "Konten konnten nicht verknüpft werden: %{errors}" + no_accounts: "Es wurden keine Konten zur Verknüpfung ausgewählt." + preload_accounts: + not_configured: "SnapTrade ist nicht konfiguriert." + select_accounts: + not_configured: "SnapTrade ist nicht konfiguriert." + select_existing_account: + not_found: "Konto oder SnapTrade-Konfiguration nicht gefunden." + title: "Mit SnapTrade-Konto verknüpfen" + header: "Bestehendes Konto verknüpfen" + subtitle: "Wählen Sie ein SnapTrade-Konto zur Verknüpfung" + no_accounts: "Keine unverknüpften SnapTrade-Konten verfügbar." + connect_hint: "Möglicherweise müssen Sie zuerst einen Broker verbinden." + settings_link: "Zu den Provider-Einstellungen" + linking_to: "Verknüpfe mit Konto:" + balance_label: "Saldo:" + link_button: "Verknüpfen" + cancel_button: "Abbrechen" + link_existing_account: + success: "Erfolgreich mit SnapTrade-Konto verknüpft." + failed: "Verknüpfung fehlgeschlagen: %{message}" + not_found: "Konto nicht gefunden." + connections: + unknown_brokerage: "Unbekannter Broker" + delete_connection: + success: "Verbindung erfolgreich gelöscht. Ein Platz ist frei." + failed: "Löschen der Verbindung fehlgeschlagen: %{message}" + missing_authorization_id: "Autorisierungs-ID fehlt" + api_deletion_failed: "Verbindung konnte bei SnapTrade nicht gelöscht werden – Zugangsdaten fehlen. Die Verbindung kann in Ihrem SnapTrade-Konto noch existieren." + delete_orphaned_user: + success: "Verwaiste Registrierung wurde erfolgreich gelöscht." + failed: "Löschen der verwaisten Registrierung fehlgeschlagen." + setup_accounts: + title: "SnapTrade-Konten einrichten" + header: "SnapTrade-Konten einrichten" + subtitle: "Wählen Sie die zu verknüpfenden Broker-Konten" + syncing: "Ihre Konten werden abgerufen..." + loading: "Konten werden von SnapTrade geladen..." + loading_hint: "Klicken Sie auf Aktualisieren, um nach Konten zu suchen." + refresh: "Aktualisieren" + info_title: "SnapTrade-Anlagedaten" + info_holdings: "Bestände mit aktuellen Preisen und Mengen" + info_cost_basis: "Einstandskosten pro Position (falls verfügbar)" + info_activities: "Handelshistorie mit Aktivitätslabels (Kaufen, Verkaufen, Dividende usw.)" + info_history: "Bis zu 3 Jahre Buchungshistorie" + free_tier_note: "SnapTrade Free Tier erlaubt 5 Broker-Verbindungen. Nutzung im SnapTrade-Dashboard prüfen." + no_accounts_title: "Keine Konten gefunden" + no_accounts_message: "Es wurden keine Broker-Konten gefunden. Das kann passieren, wenn Sie die Verbindung abgebrochen haben oder Ihr Broker nicht unterstützt wird." + try_again: "Broker verbinden" + back_to_settings: "Zurück zu Einstellungen" + available_accounts: "Verfügbare Konten" + balance_label: "Saldo:" + account_number: "Konto:" + create_button: "Ausgewählte Konten anlegen" + cancel_button: "Abbrechen" + creating: "Konten werden angelegt..." + done_button: "Fertig" + or_link_existing: "Oder mit einem bestehenden Konto verknüpfen statt neu anlegen:" + select_account: "Konto auswählen..." + link_button: "Verknüpfen" + linked_accounts: "Bereits verknüpft" + linked_to: "Verknüpft mit:" + snaptrade_item: + accounts_need_setup: + one: "%{count} Konto muss eingerichtet werden" + other: "%{count} Konten müssen eingerichtet werden" + deletion_in_progress: "Löschung läuft..." + syncing: "Synchronisiere..." + requires_update: "Verbindung muss aktualisiert werden" + error: "Sync-Fehler" + status: "Zuletzt synchronisiert vor %{timestamp} – %{summary}" + status_never: "Noch nie synchronisiert" + reconnect: "Erneut verbinden" + connect_brokerage: "Broker verbinden" + add_another_brokerage: "Weiteren Broker verbinden" + delete: "Löschen" + setup_needed: "Konten müssen eingerichtet werden" + setup_description: "Einige Konten von SnapTrade müssen Sure-Konten zugeordnet werden." + setup_action: "Konten einrichten" + setup_accounts_menu: "Konten einrichten" + manage_connections: "Verbindungen verwalten" + more_accounts_available: + one: "%{count} weiteres Konto kann eingerichtet werden" + other: "%{count} weitere Konten können eingerichtet werden" + no_accounts_title: "Keine Konten gefunden" + no_accounts_description: "Verbinden Sie einen Broker, um Ihre Anlagekonten zu importieren." + + providers: + snaptrade: + name: "SnapTrade" + connection_description: "Verbinden Sie Ihren Broker über SnapTrade (25+ Broker unterstützt)" + description: "SnapTrade verbindet mit 25+ großen Brokern (Fidelity, Vanguard, Schwab, Robinhood usw.) und liefert vollständige Handelshistorie mit Aktivitätslabels und Einstandskosten." + setup_title: "Einrichtungsanleitung:" + step_1_html: "Konto erstellen unter dashboard.snaptrade.com" + step_2: "Client ID und Consumer Key aus dem Dashboard kopieren" + step_3: "Zugangsdaten unten eintragen und auf Speichern klicken" + step_4: "Auf die Konten-Seite gehen und „Weiteren Broker verbinden“ nutzen, um Ihre Anlagekonten zu verknüpfen" + free_tier_warning: "Free Tier enthält 5 Broker-Verbindungen. Weitere erfordern einen kostenpflichtigen SnapTrade-Plan." + client_id_label: "Client ID" + client_id_placeholder: "SnapTrade Client ID eingeben" + client_id_update_placeholder: "Neue Client ID zum Aktualisieren eingeben" + consumer_key_label: "Consumer Key" + consumer_key_placeholder: "SnapTrade Consumer Key eingeben" + consumer_key_update_placeholder: "Neuen Consumer Key zum Aktualisieren eingeben" + save_button: "Konfiguration speichern" + update_button: "Konfiguration aktualisieren" + status_connected: + one: "%{count} Konto von SnapTrade" + other: "%{count} Konten von SnapTrade" + needs_setup: + one: "%{count} muss eingerichtet werden" + other: "%{count} müssen eingerichtet werden" + status_ready: "Bereit zum Verbinden von Brokern" + status_needs_registration: "Zugangsdaten gespeichert. Gehen Sie zur Konten-Seite, um Broker zu verbinden." + status_not_configured: "Nicht konfiguriert" + setup_accounts_button: "Konten einrichten" + connect_button: "Broker verbinden" + connected_brokerages: "Verbunden:" + manage_connections: "Verbindungen verwalten" + connection_limit_info: "SnapTrade Free Tier erlaubt 5 Broker-Verbindungen. Löschen Sie ungenutzte Verbindungen, um Plätze freizugeben." + loading_connections: "Verbindungen werden geladen..." + connections_error: "Verbindungen konnten nicht geladen werden: %{message}" + accounts_count: + one: "%{count} Konto" + other: "%{count} Konten" + orphaned_connection: "Verwaiste Verbindung (lokal nicht synchronisiert)" + needs_linking: "muss verknüpft werden" + no_connections: "Keine Broker-Verbindungen gefunden." + delete_connection: "Löschen" + delete_connection_title: "Broker-Verbindung löschen?" + delete_connection_body: "Die Verbindung zu %{brokerage} wird dauerhaft von SnapTrade entfernt. Alle Konten dieses Brokers werden getrennt. Zum erneuten Sync müssen Sie sich wieder verbinden." + delete_connection_confirm: "Verbindung löschen" + orphaned_users_title: + one: "%{count} verwaiste Registrierung" + other: "%{count} verwaiste Registrierungen" + orphaned_users_description: "Das sind frühere SnapTrade-Registrierungen, die Ihre Verbindungsplätze belegen. Löschen Sie sie, um Plätze freizugeben." + orphaned_user: "Verwaiste Registrierung" + delete_orphaned_user: "Löschen" + delete_orphaned_user_title: "Verwaiste Registrierung löschen?" + delete_orphaned_user_body: "Diese verwaiste SnapTrade-Registrierung und alle zugehörigen Broker-Verbindungen werden dauerhaft gelöscht, Verbindungsplätze werden frei." + delete_orphaned_user_confirm: "Registrierung löschen" + + snaptrade_item: + sync_status: + no_accounts: "Keine Konten gefunden" + synced: + one: "%{count} Konto synchronisiert" + other: "%{count} Konten synchronisiert" + synced_with_setup: "%{linked} synchronisiert, %{unlinked} müssen eingerichtet werden" + institution_summary: + none: "Keine Institute verbunden" + count: + one: "%{count} Institut" + other: "%{count} Institute" + brokerage_summary: + none: "Keine Broker verbunden" + count: + one: "%{count} Broker" + other: "%{count} Broker" + syncer: + discovering: "Konten werden ermittelt..." + importing: "Konten werden von SnapTrade importiert..." + processing: "Bestände und Aktivitäten werden verarbeitet..." + calculating: "Salden werden berechnet..." + checking_config: "Kontokonfiguration wird geprüft..." + needs_setup: "%{count} Konten müssen eingerichtet werden..." + activities_fetching_async: "Aktivitäten werden im Hintergrund geladen. Bei neuen Broker-Verbindungen kann das bis zu einer Minute dauern." diff --git a/config/locales/views/snaptrade_items/en.yml b/config/locales/views/snaptrade_items/en.yml index 63b70a28c..e9cdfd250 100644 --- a/config/locales/views/snaptrade_items/en.yml +++ b/config/locales/views/snaptrade_items/en.yml @@ -17,7 +17,9 @@ en: success: one: "Successfully linked %{count} account." other: "Successfully linked %{count} accounts." - partial_success: "Linked %{linked} account(s). %{failed} failed to link." + partial_success: + one: "Linked %{count} account. %{failed_count} failed to link." + other: "Linked %{count} accounts. %{failed_count} failed to link." link_failed: "Failed to link accounts: %{errors}" no_accounts: "No accounts were selected for linking." preload_accounts: diff --git a/config/locales/views/snaptrade_items/es.yml b/config/locales/views/snaptrade_items/es.yml new file mode 100644 index 000000000..47a6ca609 --- /dev/null +++ b/config/locales/views/snaptrade_items/es.yml @@ -0,0 +1,190 @@ +--- +es: + snaptrade_items: + default_name: "Conexión de SnapTrade" + create: + success: "SnapTrade configurado correctamente." + update: + success: "Configuración de SnapTrade actualizada correctamente." + destroy: + success: "Conexión de SnapTrade programada para su eliminación." + connect: + decryption_failed: "No se han podido leer las credenciales de SnapTrade. Por favor, elimina y vuelve a crear esta conexión." + connection_failed: "Error al conectar con SnapTrade: %{message}" + callback: + success: "¡Bróker conectado! Por favor, selecciona qué cuentas quieres vincular." + no_item: "No se ha encontrado la configuración de SnapTrade." + complete_account_setup: + success: + one: "Se ha vinculado %{count} cuenta correctamente." + other: "Se han vinculado %{count} cuentas correctamente." + partial_success: + one: "Se ha vinculado %{count} cuenta. %{failed_count} ha fallado." + other: "Se han vinculado %{count} cuentas. %{failed_count} han fallado." + link_failed: "Error al vincular las cuentas: %{errors}" + no_accounts: "No se ha seleccionado ninguna cuenta para vincular." + preload_accounts: + not_configured: "SnapTrade no está configurado." + select_accounts: + not_configured: "SnapTrade no está configurado." + select_existing_account: + not_found: "No se ha encontrado la cuenta o la configuración de SnapTrade." + title: "Vincular a cuenta de SnapTrade" + header: "Vincular cuenta existente" + subtitle: "Selecciona una cuenta de SnapTrade para realizar la vinculación" + no_accounts: "No hay cuentas de SnapTrade disponibles sin vincular." + connect_hint: "Es posible que primero debas conectar un bróker." + settings_link: "Ir a Ajustes del proveedor" + linking_to: "Vinculando a la cuenta:" + balance_label: "Saldo:" + link_button: "Vincular" + cancel_button: "Cancelar" + link_existing_account: + success: "Vinculado correctamente a la cuenta de SnapTrade." + failed: "Error al vincular la cuenta: %{message}" + not_found: "Cuenta no encontrada." + connections: + unknown_brokerage: "Bróker desconocido" + delete_connection: + success: "Conexión eliminada correctamente. Se ha liberado un espacio." + failed: "Error al eliminar la conexión: %{message}" + missing_authorization_id: "Falta el ID de autorización" + api_deletion_failed: "No se pudo eliminar la conexión de SnapTrade por falta de credenciales. Es posible que la conexión aún exista en tu cuenta de SnapTrade." + delete_orphaned_user: + success: "Registro huérfano eliminado correctamente." + failed: "Error al eliminar el registro huérfano." + setup_accounts: + title: "Configurar cuentas de SnapTrade" + header: "Configura tus cuentas de SnapTrade" + subtitle: "Selecciona qué cuentas de bróker quieres vincular" + syncing: "Obteniendo tus cuentas..." + loading: "Obteniendo cuentas de SnapTrade..." + loading_hint: "Haz clic en Actualizar para buscar cuentas." + refresh: "Actualizar" + info_title: "Datos de inversión de SnapTrade" + info_holdings: "Posiciones con precios y cantidades actuales" + info_cost_basis: "Base de costes por posición (cuando esté disponible)" + info_activities: "Historial de operaciones con etiquetas de actividad (Compra, Venta, Dividendo, etc.)" + info_history: "Hasta 3 años de historial de transacciones" + free_tier_note: "El nivel gratuito de SnapTrade permite 5 conexiones de bróker. Consulta tu panel de SnapTrade para ver el uso actual." + no_accounts_title: "No se han encontrado cuentas" + no_accounts_message: "No se han encontrado cuentas de bróker. Esto puede ocurrir si cancelaste la conexión o si tu bróker no es compatible." + try_again: "Conectar bróker" + back_to_settings: "Volver a Ajustes" + available_accounts: "Cuentas disponibles" + balance_label: "Saldo:" + account_number: "Cuenta:" + create_button: "Crear cuentas seleccionadas" + cancel_button: "Cancelar" + creating: "Creando cuentas..." + done_button: "Listo" + or_link_existing: "O vincula a una cuenta existente en lugar de crear una nueva:" + select_account: "Selecciona una cuenta..." + link_button: "Vincular" + linked_accounts: "Ya vinculadas" + linked_to: "Vinculada a:" + snaptrade_item: + accounts_need_setup: + one: "%{count} cuenta necesita configuración" + other: "%{count} cuentas necesitan configuración" + deletion_in_progress: "Eliminación en curso..." + syncing: "Sincronizando..." + requires_update: "La conexión necesita una actualización" + error: "Error de sincronización" + status: "Sincronizado hace %{timestamp} - %{summary}" + status_never: "Nunca sincronizado" + reconnect: "Reconectar" + connect_brokerage: "Conectar bróker" + add_another_brokerage: "Conectar otro bróker" + delete: "Eliminar" + setup_needed: "Las cuentas necesitan configuración" + setup_description: "Algunas cuentas de SnapTrade deben vincularse a cuentas de Sure." + setup_action: "Configurar cuentas" + setup_accounts_menu: "Configurar cuentas" + manage_connections: "Gestionar conexiones" + more_accounts_available: + one: "Hay %{count} cuenta más disponible para configurar" + other: "Hay %{count} cuentas más disponibles para configurar" + no_accounts_title: "No se han detectado cuentas" + no_accounts_description: "Conecta un bróker para importar tus cuentas de inversión." + + providers: + snaptrade: + name: "SnapTrade" + connection_description: "Conecta con tu bróker a través de SnapTrade (más de 25 brókers compatibles)" + description: "SnapTrade conecta con más de 25 brókers principales (Fidelity, Vanguard, Schwab, Robinhood, etc.) y proporciona un historial completo de operaciones con etiquetas de actividad y base de costes." + setup_title: "Instrucciones de configuración:" + step_1_html: "Crea una cuenta en dashboard.snaptrade.com" + step_2: "Copia tu Client ID y tu Consumer Key desde el panel" + step_3: "Introduce tus credenciales a continuación y haz clic en Guardar" + step_4: "Ve a la página de Cuentas y usa 'Conectar otro bróker' para vincular tus cuentas de inversión" + free_tier_warning: "El nivel gratuito incluye 5 conexiones de bróker. Las conexiones adicionales requieren un plan de pago de SnapTrade." + client_id_label: "Client ID" + client_id_placeholder: "Introduce tu Client ID de SnapTrade" + client_id_update_placeholder: "Introduce el nuevo Client ID para actualizar" + consumer_key_label: "Consumer Key" + consumer_key_placeholder: "Introduce tu Consumer Key de SnapTrade" + consumer_key_update_placeholder: "Introduce la nueva Consumer Key para actualizar" + save_button: "Guardar configuración" + update_button: "Actualizar configuración" + status_connected: + one: "%{count} cuenta de SnapTrade" + other: "%{count} cuentas de SnapTrade" + needs_setup: + one: "%{count} necesita configuración" + other: "%{count} necesitan configuración" + status_ready: "Listo para conectar brókers" + status_needs_registration: "Credenciales guardadas. Ve a la página de Cuentas para conectar brókers." + status_not_configured: "No configurado" + setup_accounts_button: "Configurar cuentas" + connect_button: "Conectar bróker" + connected_brokerages: "Conectados:" + manage_connections: "Gestionar conexiones" + connection_limit_info: "El nivel gratuito de SnapTrade permite 5 conexiones de bróker. Elimina conexiones sin usar para liberar espacios." + loading_connections: "Cargando conexiones..." + connections_error: "Error al cargar las conexiones: %{message}" + accounts_count: + one: "%{count} cuenta" + other: "%{count} cuentas" + orphaned_connection: "Conexión huérfana (no sincronizada localmente)" + needs_linking: "necesita vincularse" + no_connections: "No se han encontrado conexiones de bróker." + delete_connection: "Eliminar" + delete_connection_title: "¿Eliminar conexión del bróker?" + delete_connection_body: "Esto eliminará permanentemente la conexión de %{brokerage} de SnapTrade. Todas las cuentas de este bróker se desvincularán. Deberás volver a conectar para sincronizar estas cuentas de nuevo." + delete_connection_confirm: "Eliminar conexión" + orphaned_users_title: + one: "%{count} registro huérfano" + other: "%{count} registros huérfanos" + orphaned_users_description: "Estos son registros de usuario de SnapTrade anteriores que están ocupando tus espacios de conexión. Elimínalos para liberar espacio." + orphaned_user: "Registro huérfano" + delete_orphaned_user: "Eliminar" + delete_orphaned_user_title: "¿Eliminar registro huérfano?" + delete_orphaned_user_body: "Esto eliminará permanentemente este usuario de SnapTrade huérfano y todas sus conexiones de bróker, liberando espacios de conexión." + delete_orphaned_user_confirm: "Eliminar registro" + + snaptrade_item: + sync_status: + no_accounts: "No se han encontrado cuentas" + synced: + one: "%{count} cuenta sincronizada" + other: "%{count} cuentas sincronizadas" + synced_with_setup: "%{linked} sincronizadas, %{unlinked} necesitan configuración" + institution_summary: + none: "No hay instituciones conectadas" + count: + one: "%{count} institución" + other: "%{count} instituciones" + brokerage_summary: + none: "No hay brókers conectados" + count: + one: "%{count} bróker" + other: "%{count} brókers" + syncer: + discovering: "Detectando cuentas..." + importing: "Importando cuentas de SnapTrade..." + processing: "Procesando posiciones y actividades..." + calculating: "Calculando saldos..." + checking_config: "Comprobando la configuración de la cuenta..." + needs_setup: "%{count} cuentas necesitan configuración..." + activities_fetching_async: "Las actividades se están obteniendo en segundo plano. Esto puede tardar hasta un minuto para conexiones de bróker recientes." \ No newline at end of file diff --git a/config/locales/views/snaptrade_items/pl.yml b/config/locales/views/snaptrade_items/pl.yml new file mode 100644 index 000000000..ba3eb7058 --- /dev/null +++ b/config/locales/views/snaptrade_items/pl.yml @@ -0,0 +1,210 @@ +--- +pl: + snaptrade_items: + default_name: Połączenie SnapTrade + create: + success: Pomyślnie skonfigurowano SnapTrade. + update: + success: Pomyślnie zaktualizowano konfigurację SnapTrade. + destroy: + success: Zaplanowano usunięcie połączenia SnapTrade. + connect: + decryption_failed: Nie można odczytać danych uwierzytelniających SnapTrade. Usuń i utwórz ponownie to połączenie. + connection_failed: 'Nie udało się połączyć z SnapTrade: %{message}' + callback: + success: Biuro maklerskie zostało połączone! Wybierz konta do połączenia. + no_item: Nie znaleziono konfiguracji SnapTrade. + complete_account_setup: + success: + one: Pomyślnie połączono %{count} konto. + few: Pomyślnie połączono %{count} konta. + many: Pomyślnie połączono %{count} kont. + other: Pomyślnie połączono %{count} konta. + partial_success: + one: "Połączono %{count} konto. Nie udało się połączyć %{failed_count}." + few: "Połączono %{count} konta. Nie udało się połączyć %{failed_count}." + many: "Połączono %{count} kont. Nie udało się połączyć %{failed_count}." + other: "Połączono %{count} konta. Nie udało się połączyć %{failed_count}." + link_failed: 'Nie udało się połączyć kont: %{errors}' + no_accounts: Nie wybrano żadnych kont do połączenia. + preload_accounts: + not_configured: SnapTrade nie jest skonfigurowane. + select_accounts: + not_configured: SnapTrade nie jest skonfigurowane. + select_existing_account: + not_found: Konto lub konfiguracja SnapTrade nie zostało znalezione. + title: Połącz z kontem SnapTrade + header: Połącz istniejące konto + subtitle: Wybierz konto SnapTrade do połączenia + no_accounts: Brak niepołączonych kont SnapTrade. + connect_hint: Może najpierw musisz połączyć biuro maklerskie. + settings_link: Przejdź do ustawień dostawcy + linking_to: 'Łączenie z kontem:' + balance_label: 'Saldo:' + link_button: Połącz + cancel_button: Anuluj + link_existing_account: + success: Pomyślnie połączono z kontem SnapTrade. + failed: 'Nie udało się połączyć konta: %{message}' + not_found: Konto nie zostało znalezione. + connections: + unknown_brokerage: Nieznane biuro maklerskie + delete_connection: + success: Połączenie zostało pomyślnie usunięte. Zwolniono jedno miejsce. + failed: 'Nie udało się usunąć połączenia: %{message}' + missing_authorization_id: Brak identyfikatora autoryzacji + api_deletion_failed: Nie można usunąć połączenia z SnapTrade — brak danych uwierzytelniających. Połączenie może nadal istnieć na koncie SnapTrade. + delete_orphaned_user: + success: Osierocona rejestracja została usunięta pomyślnie. + failed: Nie udało się usunąć osieroconej rejestracji. + setup_accounts: + title: Skonfiguruj konta SnapTrade + header: Skonfiguruj swoje konta SnapTrade + subtitle: Wybierz konta maklerskie do połączenia + syncing: Pobieranie kont... + loading: Pobieranie kont z SnapTrade... + loading_hint: Kliknij Odśwież, aby sprawdzić konta. + refresh: Odśwież + info_title: Dane inwestycyjne SnapTrade + info_holdings: Aktywa z aktualnymi cenami i ilościami + info_cost_basis: Podstawa kosztowa na pozycję (gdy dostępna) + info_activities: Historia transakcji z etykietami aktywności (Kupno, Sprzedaż, Dywidenda itp.) + info_history: Do 3 lat historii transakcji + free_tier_note: Plan bezpłatny SnapTrade umożliwia 5 połączeń z biurami maklerskimi. Sprawdź swój pulpit SnapTrade, aby zobaczyć bieżące użycie. + no_accounts_title: Nie znaleziono kont + no_accounts_message: Nie znaleziono kont maklerskich. Może to nastąpić, jeśli anulowałeś połączenie lub Twoje biuro maklerskie nie jest obsługiwane. + try_again: Połącz biuro maklerskie + back_to_settings: Powrót do ustawień + available_accounts: Dostępne konta + balance_label: 'Saldo:' + account_number: 'Konto:' + create_button: Utwórz wybrane konta + cancel_button: Anuluj + creating: Tworzenie kont... + done_button: Gotowe + or_link_existing: 'Lub połącz z istniejącym kontem zamiast tworzyć nowe:' + select_account: Wybierz konto... + link_button: Połącz + linked_accounts: Już połączone + linked_to: 'Połączono z:' + snaptrade_item: + accounts_need_setup: + one: "%{count} konto wymaga konfiguracji" + few: "%{count} konta wymagają konfiguracji" + many: "%{count} kont wymaga konfiguracji" + other: "%{count} kont wymaga konfiguracji" + deletion_in_progress: Usuwanie w toku... + syncing: Synchronizacja... + requires_update: Połączenie wymaga aktualizacji + error: Błąd synchronizacji + status: Ostatnia synchronizacja %{timestamp} temu — %{summary} + status_never: Nigdy nie synchronizowano + reconnect: Połącz ponownie + connect_brokerage: Połącz biuro maklerskie + add_another_brokerage: Połącz kolejne biuro maklerskie + delete: Usuń + setup_needed: Konta wymagają konfiguracji + setup_description: Niektóre konta z SnapTrade muszą zostać połączone z kontami Sure. + setup_action: Konfiguruj konta + setup_accounts_menu: Skonfiguruj konta + manage_connections: Zarządzaj połączeniami + more_accounts_available: + one: "%{count} dodatkowe konto dostępne do konfiguracji" + few: "%{count} dodatkowe konta dostępne do konfiguracji" + many: "%{count} dodatkowych kont dostępnych do konfiguracji" + other: "%{count} dodatkowych kont dostępnych do konfiguracji" + no_accounts_title: Nie wykryto kont + no_accounts_description: Połącz biuro maklerskie, aby zaimportować swoje konta inwestycyjne. + providers: + snaptrade: + name: SnapTrade + connection_description: Połącz się ze swoim biurem maklerskim przez SnapTrade (obsługuje 25+ brokerów) + description: SnapTrade łączy się z ponad 25 głównymi biurami maklerskimi (Fidelity, Vanguard, Schwab, Robinhood itp.) i udostępnia pełną historię transakcji z etykietami aktywności oraz podstawą kosztową. + setup_title: 'Instrukcje konfiguracji:' + step_1_html: Utwórz konto na dashboard.snaptrade.com + step_2: Skopiuj swój Client ID i Consumer Key z pulpitu nawigacyjnego + step_3: Wprowadź swoje dane uwierzytelniające poniżej i kliknij Zapisz + step_4: Przejdź do strony Konta i użyj opcji „Połącz kolejne biuro maklerskie", aby połączyć swoje konta inwestycyjne + free_tier_warning: Plan bezpłatny obejmuje 5 połączeń z biurami maklerskimi. Dodatkowe połączenia wymagają płatnego planu SnapTrade. + client_id_label: Client ID + client_id_placeholder: Wprowadź Client ID SnapTrade + client_id_update_placeholder: Wprowadź nowy Client ID, aby zaktualizować + consumer_key_label: Consumer Key + consumer_key_placeholder: Wprowadź Consumer Key SnapTrade + consumer_key_update_placeholder: Wprowadź nowy Consumer Key, aby zaktualizować + save_button: Zapisz konfigurację + update_button: Zaktualizuj konfigurację + status_connected: + one: "%{count} konto z SnapTrade" + few: "%{count} konta z SnapTrade" + many: "%{count} kont z SnapTrade" + other: "%{count} konta z SnapTrade" + needs_setup: + one: "%{count} wymaga konfiguracji" + few: "%{count} wymagają konfiguracji" + many: "%{count} wymaga konfiguracji" + other: "%{count} wymaga konfiguracji" + status_ready: Gotowe do połączenia z biurami maklerskimi + status_needs_registration: Dane uwierzytelniające zapisane. Przejdź do strony Konta, aby połączyć biura maklerskie. + status_not_configured: Nieskonfigurowane + setup_accounts_button: Konfiguruj konta + connect_button: Połącz biuro maklerskie + connected_brokerages: 'Połączone:' + manage_connections: Zarządzaj połączeniami + connection_limit_info: Plan bezpłatny SnapTrade umożliwia 5 połączeń z biurami maklerskimi. Usuń nieużywane połączenia, aby zwolnić miejsca. + loading_connections: Ładowanie połączeń... + connections_error: 'Nie udało się załadować połączeń: %{message}' + accounts_count: + one: "%{count} konto" + few: "%{count} konta" + many: "%{count} kont" + other: "%{count} kont" + orphaned_connection: Osierocone połączenie (niezsynchronizowane lokalnie) + needs_linking: wymaga połączenia + no_connections: Nie znaleziono połączeń z biurami maklerskimi. + delete_connection: Usuń + delete_connection_title: Usunąć połączenie z biurem maklerskim? + delete_connection_body: Spowoduje to trwałe usunięcie połączenia %{brokerage} z SnapTrade. Wszystkie konta tego biura maklerskiego zostaną odłączone. Będziesz musiał ponownie się połączyć, aby synchronizować te konta. + delete_connection_confirm: Usuń połączenie + orphaned_users_title: + one: "%{count} osierocona rejestracja" + few: "%{count} osierocone rejestracje" + many: "%{count} osieroconych rejestracji" + other: "%{count} osieroconych rejestracji" + orphaned_users_description: Są to poprzednie rejestracje użytkowników SnapTrade, które zajmują Twoje miejsca połączeń. Usuń je, aby zwolnić miejsca. + orphaned_user: Osierocona rejestracja + delete_orphaned_user: Usuń + delete_orphaned_user_title: Usunąć osieroconą rejestrację? + delete_orphaned_user_body: Spowoduje to trwałe usunięcie tej osieroconej rejestracji użytkownika SnapTrade i wszystkich jego połączeń z biurami maklerskimi, zwalniając miejsca połączeń. + delete_orphaned_user_confirm: Usuń rejestrację + snaptrade_item: + sync_status: + no_accounts: Nie znaleziono kont + synced: + one: "%{count} konto zsynchronizowane" + few: "%{count} konta zsynchronizowane" + many: "%{count} kont zsynchronizowanych" + other: "%{count} kont zsynchronizowanych" + synced_with_setup: "%{linked} zsynchronizowane, %{unlinked} wymagają konfiguracji" + institution_summary: + none: Brak połączonych instytucji + count: + one: "%{count} instytucja" + few: "%{count} instytucje" + many: "%{count} instytucji" + other: "%{count} instytucji" + brokerage_summary: + none: Brak połączonych biur maklerskich + count: + one: "%{count} biuro maklerskie" + few: "%{count} biura maklerskie" + many: "%{count} biur maklerskich" + other: "%{count} biur maklerskich" + syncer: + discovering: Odnajdywanie kont... + importing: Importowanie kont z SnapTrade... + processing: Przetwarzanie aktywów i aktywności... + calculating: Obliczanie sald... + checking_config: Sprawdzanie konfiguracji konta... + needs_setup: "%{count} kont wymaga konfiguracji..." + activities_fetching_async: Aktywności są pobierane w tle. Może to potrwać do minuty dla nowych połączeń z biurami maklerskimi. diff --git a/config/locales/views/splits/en.yml b/config/locales/views/splits/en.yml new file mode 100644 index 000000000..db9a5e037 --- /dev/null +++ b/config/locales/views/splits/en.yml @@ -0,0 +1,47 @@ +--- +en: + splits: + new: + title: Split Transaction + description: Split this transaction into multiple entries with different categories and amounts. + submit: Split Transaction + cancel: Cancel + add_row: Add split + remove_row: Remove + remaining: Remaining + amounts_must_match: Split amounts must equal the original transaction amount. + name_label: Name + name_placeholder: Split name + amount_label: Amount + category_label: Category + uncategorized: "(uncategorized)" + original_name: "Name:" + original_date: "Date:" + original_amount: "Amount" + split_number: "Split #%{number}" + create: + success: Transaction split successfully + not_splittable: This transaction cannot be split. + destroy: + success: Transaction unsplit successfully + show: + title: Split Entries + description: This transaction has been split into the following entries. + button_title: Split Transaction + button_description: Split this transaction into multiple entries with different categories and amounts. + button: Split + unsplit_title: Unsplit Transaction + unsplit_button: Unsplit + unsplit_confirm: This will remove all split entries and restore the original transaction. + edit: + title: Edit Split + description: Modify the split entries for this transaction. + submit: Update Split + not_split: This transaction is not split. + update: + success: Split updated successfully + child: + title: Part of Split + description: This entry is part of a split transaction. + edit_split: Edit Split + unsplit: Unsplit diff --git a/config/locales/views/splits/pl.yml b/config/locales/views/splits/pl.yml new file mode 100644 index 000000000..01e4cb19c --- /dev/null +++ b/config/locales/views/splits/pl.yml @@ -0,0 +1,47 @@ +--- +pl: + splits: + new: + title: Podziel transakcję + description: Podziel tę transakcję na wiele wpisów z różnymi kategoriami i kwotami. + submit: Podziel transakcję + cancel: Anuluj + add_row: Dodaj podział + remove_row: Usuń + remaining: Pozostało + amounts_must_match: Kwoty podziału muszą być równe kwocie oryginalnej transakcji. + name_label: Nazwa + name_placeholder: Nazwa podziału + amount_label: Kwota + category_label: Kategoria + uncategorized: "(bez kategorii)" + original_name: 'Nazwa:' + original_date: 'Data:' + original_amount: Kwota + split_number: 'Podział nr %{number}' + create: + success: Transakcja została pomyślnie podzielona + not_splittable: Tej transakcji nie można podzielić. + destroy: + success: Podział transakcji został pomyślnie cofnięty + show: + title: Podzielone wpisy + description: Ta transakcja została podzielona na poniższe wpisy. + button_title: Podziel transakcję + button_description: Podziel tę transakcję na wiele wpisów z różnymi kategoriami i kwotami. + button: Podziel + unsplit_title: Cofnij podział transakcji + unsplit_button: Cofnij podział + unsplit_confirm: To usunie wszystkie wpisy podziału i przywróci oryginalną transakcję. + edit: + title: Edytuj podział + description: Zmodyfikuj wpisy podziału dla tej transakcji. + submit: Zaktualizuj podział + not_split: Ta transakcja nie jest podzielona. + update: + success: Podział został pomyślnie zaktualizowany + child: + title: Część podziału + description: Ten wpis jest częścią podzielonej transakcji. + edit_split: Edytuj podział + unsplit: Cofnij podział diff --git a/config/locales/views/subscriptions/pl.yml b/config/locales/views/subscriptions/pl.yml new file mode 100644 index 000000000..d40212625 --- /dev/null +++ b/config/locales/views/subscriptions/pl.yml @@ -0,0 +1,18 @@ +--- +pl: + subscriptions: + self_hosted_alert: "%{product_name} nie jest dostępny w trybie self-hosted." + upgrade: + contribute_and_support_sure: "Wspieraj i rozwijaj Sure" + cta: "Wesprzyj dalszy rozwój tej bazy kodu!" + header: + support: "Wesprzyj" + sure: "Sure" + today: "już dziś" + redirect_to_stripe: "W kolejnym kroku nastąpi przekierowanie do Stripe, który obsługuje płatności kartą." + trialing: + one: "Twoje dane zostaną usunięte za %{count} dzień" + few: "Twoje dane zostaną usunięte za %{count} dni" + many: "Twoje dane zostaną usunięte za %{count} dni" + other: "Twoje dane zostaną usunięte za %{count} dnia" + trial_over: "Okres próbny dobiegł końca" diff --git a/config/locales/views/tag/deletions/pl.yml b/config/locales/views/tag/deletions/pl.yml new file mode 100644 index 000000000..0b29e4ca1 --- /dev/null +++ b/config/locales/views/tag/deletions/pl.yml @@ -0,0 +1,13 @@ +--- +pl: + tag: + deletions: + create: + deleted: Tag został usunięty + new: + delete_and_leave_uncategorized: Usuń "%{tag_name}" + delete_and_recategorize: Usuń "%{tag_name}" i przypisz nowy tag + delete_tag: Usunąć tag? + explanation: "%{tag_name} zostanie usunięty z transakcji i innych obiektów oznaczonych tagami. Zamiast pozostawiać je bez tagu, możesz poniżej przypisać nowy tag." + replacement_tag_prompt: Wybierz tag + tag: Tag diff --git a/config/locales/views/tags/pl.yml b/config/locales/views/tags/pl.yml new file mode 100644 index 000000000..af9624452 --- /dev/null +++ b/config/locales/views/tags/pl.yml @@ -0,0 +1,23 @@ +--- +pl: + tags: + create: + created: Tag został utworzony + error: 'Błąd podczas tworzenia tagu: %{error}' + destroy: + deleted: Tag został usunięty + edit: + edit: Edytuj tag + form: + placeholder: Nazwa tagu + index: + empty: Brak tagów + new: Nowy tag + tags: Tagi + new: + new: Nowy tag + tag: + delete: Usuń + edit: Edytuj + update: + updated: Tag został zaktualizowany diff --git a/config/locales/views/trades/de.yml b/config/locales/views/trades/de.yml index 51417c1f9..912d5bd9a 100644 --- a/config/locales/views/trades/de.yml +++ b/config/locales/views/trades/de.yml @@ -6,24 +6,37 @@ de: account_prompt: Konto suchen amount: Betrag holding: Tickersymbol + holding_optional: Tickersymbol (optional) price: Preis pro Anteil qty: Menge submit: Transaktion hinzufügen ticker_placeholder: AAPL type: Typ + type_buy: Kaufen + type_sell: Verkaufen + type_deposit: Einzahlung + type_withdrawal: Auszahlung + type_dividend: Dividende + type_interest: Zinsen + dividend_requires_security: Für Dividenden ist ein Wertpapier erforderlich header: buy: Kaufen + sell: Verkaufen + dividend: Dividende + interest: Zinsen current_market_price_label: Aktueller Marktpreis overview: Übersicht purchase_price_label: Kaufpreis purchase_qty_label: Kaufmenge - sell: Verkaufen symbol_label: Symbol total_return_label: Nicht realisierter Gewinn/Verlust new: title: Neue Transaktion show: additional: Zusätzlich + amount_label: Betrag + buy: Kaufen + category_label: Kategorie cost_per_share_label: Kosten pro Anteil date_label: Datum delete: Löschen @@ -32,7 +45,10 @@ de: details: Details exclude_subtitle: Dieser Trade wird nicht in Berichten und Berechnungen berücksichtigt exclude_title: Von Analysen ausschließen + no_category: Keine Kategorie note_label: Notiz note_placeholder: Füge hier zusätzliche Notizen hinzu … quantity_label: Menge + sell: Verkaufen settings: Einstellungen + type_label: Typ diff --git a/config/locales/views/trades/en.yml b/config/locales/views/trades/en.yml index 606975c19..b2257c26f 100644 --- a/config/locales/views/trades/en.yml +++ b/config/locales/views/trades/en.yml @@ -5,30 +5,43 @@ en: account: Transfer account (optional) account_prompt: Search account amount: Amount + fee: Transaction fee holding: Ticker symbol + holding_optional: Ticker symbol (optional) price: Price per share qty: Quantity submit: Add transaction ticker_placeholder: AAPL type: Type + type_buy: Buy + type_sell: Sell + type_deposit: Deposit + type_withdrawal: Withdrawal + type_dividend: Dividend + type_interest: Interest + dividend_requires_security: Security is required for dividends header: buy: Buy + sell: Sell + dividend: Dividend + interest: Interest current_market_price_label: Current Market Price overview: Overview purchase_price_label: Purchase Price purchase_qty_label: Purchase Quantity - sell: Sell symbol_label: Symbol total_return_label: Unrealized gain/loss new: title: New transaction show: additional: Additional + amount_label: Amount buy: Buy category_label: Category cost_per_share_label: Cost per Share date_label: Date delete: Delete + fee_label: Transaction fee delete_subtitle: This action cannot be undone delete_title: Delete Trade details: Details diff --git a/config/locales/views/trades/es.yml b/config/locales/views/trades/es.yml index 44198fb57..58c86d756 100644 --- a/config/locales/views/trades/es.yml +++ b/config/locales/views/trades/es.yml @@ -6,24 +6,37 @@ es: account_prompt: Buscar cuenta amount: Importe holding: Símbolo del ticker + holding_optional: Símbolo del ticker (opcional) price: Precio por acción qty: Cantidad submit: Añadir transacción ticker_placeholder: AAPL type: Tipo + type_buy: Comprar + type_sell: Vender + type_deposit: Depósito + type_withdrawal: Retiro + type_dividend: Dividendo + type_interest: Interés + dividend_requires_security: Se requiere un valor para los dividendos header: buy: Comprar + sell: Vender + dividend: Dividendo + interest: Interés current_market_price_label: Precio de mercado actual overview: Resumen purchase_price_label: Precio de compra purchase_qty_label: Cantidad comprada - sell: Vender symbol_label: Símbolo total_return_label: Ganancia/pérdida no realizada new: title: Nueva transacción show: additional: Adicional + amount_label: Importe + buy: Compra + category_label: Categoría cost_per_share_label: Costo por acción date_label: Fecha delete: Eliminar @@ -32,7 +45,10 @@ es: details: Detalles exclude_subtitle: Esta operación no se incluirá en informes y cálculos exclude_title: Excluir de los análisis + no_category: Sin categoría note_label: Nota note_placeholder: Añade aquí cualquier nota adicional... quantity_label: Cantidad + sell: Venta settings: Configuración + type_label: Tipo \ No newline at end of file diff --git a/config/locales/views/trades/fr.yml b/config/locales/views/trades/fr.yml index 7332d4eb6..e94a04678 100644 --- a/config/locales/views/trades/fr.yml +++ b/config/locales/views/trades/fr.yml @@ -6,24 +6,35 @@ fr: account_prompt: Rechercher un compte amount: Montant holding: Symbole boursier + holding_optional: Symbole boursier (facultatif) price: Prix par action qty: Quantité submit: Ajouter la transaction ticker_placeholder: AAPL type: Type + type_buy: Acheter + type_sell: Vendre + type_deposit: Dépôt + type_withdrawal: Retrait + type_dividend: Dividende + type_interest: Intérêts + dividend_requires_security: Un titre est requis pour les dividendes header: buy: Acheter + sell: Vendre + dividend: Dividende + interest: Intérêts current_market_price_label: Prix du marché actuel overview: Aperçu purchase_price_label: Prix d'achat purchase_qty_label: Quantité achetée - sell: Vendre symbol_label: Symbole total_return_label: Gain/perte non réalisé(e) new: title: Nouvelle transaction show: additional: Détails supplémentaires + amount_label: Montant cost_per_share_label: Coût par action date_label: Date delete: Supprimer diff --git a/config/locales/views/trades/pl.yml b/config/locales/views/trades/pl.yml new file mode 100644 index 000000000..44e16cd9d --- /dev/null +++ b/config/locales/views/trades/pl.yml @@ -0,0 +1,56 @@ +--- +pl: + trades: + form: + account: Konto transferowe (opcjonalnie) + account_prompt: Wyszukaj konto + amount: Kwota + fee: Opłata transakcyjna + holding: Symbol ticker + holding_optional: Symbol ticker (opcjonalnie) + price: Cena za akcję + qty: Ilość + submit: Dodaj transakcję + ticker_placeholder: AAPL + type: Typ + type_buy: Kupno + type_sell: Sprzedaż + type_deposit: Wpłata + type_withdrawal: Wypłata + type_dividend: Dywidenda + type_interest: Odsetki + dividend_requires_security: Dla dywidend wymagany jest papier wartościowy + header: + buy: Kupno + sell: Sprzedaż + dividend: Dywidenda + interest: Odsetki + current_market_price_label: Aktualna cena rynkowa + overview: Przegląd + purchase_price_label: Cena zakupu + purchase_qty_label: Ilość zakupu + symbol_label: Symbol + total_return_label: Niezrealizowany zysk/strata + new: + title: Nowa transakcja + show: + additional: Dodatkowe + amount_label: Kwota + buy: Kupno + category_label: Kategoria + cost_per_share_label: Koszt za akcję + date_label: Data + delete: Usuń + fee_label: Opłata transakcyjna + delete_subtitle: Tej akcji nie można cofnąć + delete_title: Usuń transakcję giełdową + details: Szczegóły + exclude_subtitle: Ta transakcja giełdowa nie będzie uwzględniana w raportach i obliczeniach + exclude_title: Wyklucz z analityki + no_category: Brak kategorii + note_label: Notatka + note_placeholder: Dodaj tutaj dodatkowe notatki... + quantity_label: Ilość + sell: Sprzedaż + settings: Ustawienia + type_label: Typ diff --git a/config/locales/views/transactions/ca.yml b/config/locales/views/transactions/ca.yml index 7a1a2ad04..dc3b94981 100644 --- a/config/locales/views/transactions/ca.yml +++ b/config/locales/views/transactions/ca.yml @@ -122,6 +122,7 @@ ca: transaction: pending: Pendent pending_tooltip: Transacció pendent — pot canviar quan es publiqui + linked_with_provider: Vinculat amb %{provider} possible_duplicate: Duplicat? potential_duplicate_tooltip: Això pot ser un duplicat d'una altra transacció review_recommended: Revisa diff --git a/config/locales/views/transactions/de.yml b/config/locales/views/transactions/de.yml index 2674005b4..2b19d6942 100644 --- a/config/locales/views/transactions/de.yml +++ b/config/locales/views/transactions/de.yml @@ -1,6 +1,7 @@ --- de: transactions: + unknown_name: Unbekannte Transaktion form: account: Konto account_prompt: Konto auswählen @@ -29,6 +30,15 @@ de: delete_subtitle: Diese Aktion löscht die Transaktion dauerhaft, beeinflusst deine bisherigen Kontostände und kann nicht rückgängig gemacht werden. delete_title: Transaktion löschen details: Details + exclude: Ausschließen + exclude_description: Ausgeschlossene Transaktionen werden aus Budgetberechnungen und Berichten entfernt. + activity_type: Aktivitätsart + activity_type_description: Art der Anlageaktivität (Kauf, Verkauf, Dividende usw.). Wird automatisch erkannt oder manuell gesetzt. + one_time_title: Einmalige %{type} + one_time_description: Einmalige Transaktionen werden aus bestimmten Budgetberechnungen und Berichten ausgeschlossen, damit du das Wichtige besser erkennst. + convert_to_trade_title: In Wertpapier-Trade umwandeln + convert_to_trade_description: Diese Transaktion in einen Kauf- oder Verkaufstrade mit Wertpapierdetails für die Portfolioverfolgung umwandeln. + convert_to_trade_button: In Trade umwandeln merchant_label: Händler name_label: Name nature: Typ @@ -38,7 +48,45 @@ de: overview: Übersicht settings: Einstellungen tags_label: Tags + tab_transactions: Transaktionen + tab_upcoming: Anstehend uncategorized: (ohne Kategorie) + activity_labels: + buy: Kaufen + sell: Verkaufen + sweep_in: Sweep In + sweep_out: Sweep Out + dividend: Dividende + reinvestment: Reinvestition + interest: Zinsen + fee: Gebühr + transfer: Überweisung + contribution: Einlage + withdrawal: Entnahme + exchange: Umtausch + other: Sonstige + mark_recurring: Als wiederkehrend markieren + mark_recurring_subtitle: Als wiederkehrende Transaktion verfolgen. Die Betragsabweichung wird automatisch aus den letzten 6 Monaten ähnlicher Transaktionen berechnet. + mark_recurring_title: Wiederkehrende Transaktion + potential_duplicate_title: Mögliches Duplikat erkannt + potential_duplicate_description: Diese ausstehende Transaktion könnte mit der unten stehenden gebuchten Transaktion übereinstimmen. Wenn ja, führe sie zusammen, um Doppelzählung zu vermeiden. + merge_duplicate: Ja, zusammenführen + keep_both: Nein, beide behalten + transaction: + pending: Ausstehend + pending_tooltip: Ausstehende Transaktion — kann sich bei Buchung ändern + linked_with_provider: Mit %{provider} verknüpft + activity_type_tooltip: Art der Anlageaktivität + possible_duplicate: Duplikat? + potential_duplicate_tooltip: Dies könnte ein Duplikat einer anderen Transaktion sein + review_recommended: Prüfen + review_recommended_tooltip: Große Betragsabweichung — Prüfung empfohlen, ob es sich um ein Duplikat handelt + merge_duplicate: + success: Transaktionen erfolgreich zusammengeführt + failure: Transaktionen konnten nicht zusammengeführt werden + dismiss_duplicate: + success: Als getrennte Transaktionen beibehalten + failure: Duplikatshinweis konnte nicht verworfen werden header: edit_categories: Kategorien bearbeiten edit_imports: Importe bearbeiten @@ -48,6 +96,65 @@ de: index: transaction: Transaktion transactions: Transaktionen + import: Import + list: + drag_drop_title: CSV zum Importieren ablegen + drag_drop_subtitle: Transaktionen direkt hochladen + transaction: Transaktion + transactions: Transaktionen + toggle_recurring_section: Anstehende wiederkehrende Transaktionen ein-/ausblenden + search: + filters: + account: Konto + date: Datum + type: Typ + status: Status + amount: Betrag + category: Kategorie + tag: Tag + merchant: Händler + convert_to_trade: + title: In Wertpapier-Trade umwandeln + description: Diese Transaktion in einen Trade mit Wertpapierdetails umwandeln + date_label: "Datum:" + account_label: "Konto:" + amount_label: "Betrag:" + security_label: Wertpapier + security_prompt: Wertpapier auswählen… + security_custom: "+ Eigenes Tickersymbol eingeben" + security_not_listed_hint: Dein Wertpapier nicht dabei? Wähle unten in der Liste „Eigenes Tickersymbol eingeben“. + ticker_placeholder: AAPL + ticker_hint: Gib das Aktien-/ETF-Tickersymbol ein (z. B. AAPL, MSFT) + ticker_search_placeholder: Nach Ticker suchen… + ticker_search_hint: Nach Tickersymbol oder Firmenname suchen oder eigenes Tickersymbol eingeben + price_mismatch_title: Preis weicht möglicherweise ab + price_mismatch_message: "Dein Preis (%{entered_price}/Aktie) weicht deutlich vom aktuellen Marktpreis von %{ticker} (%{market_price}) ab. Wenn das falsch erscheint, hast du vielleicht das falsche Wertpapier gewählt — versuche „Eigenes Tickersymbol eingeben“, um das richtige anzugeben." + quantity_label: Menge (Aktien) + quantity_placeholder: z. B. 20 + quantity_hint: Anzahl der gehandelten Aktien + price_label: Preis pro Aktie + price_placeholder: z. B. 52,15 + price_hint: Preis pro Aktie (%{currency}) + qty_or_price_hint: Gib mindestens Menge ODER Preis ein. Der andere Wert wird aus dem Transaktionsbetrag (%{amount}) berechnet. + trade_type_label: Trade-Typ + trade_type_hint: Kauf oder Verkauf von Wertpapieranteilen + exchange_label: Börse (optional) + exchange_placeholder: XNAS + exchange_hint: Leer lassen für automatische Erkennung + cancel: Abbrechen + submit: In Trade umwandeln + success: Transaktion in Trade umgewandelt + conversion_note: "Umgewandelt aus Transaktion: %{original_name} (%{original_date})" + errors: + not_investment_account: Nur Transaktionen in Anlagekonten können in Trades umgewandelt werden + already_converted: Diese Transaktion wurde bereits umgewandelt oder ausgeschlossen + enter_ticker: Bitte gib ein Tickersymbol ein + security_not_found: Das gewählte Wertpapier existiert nicht mehr. Bitte wähle ein anderes. + select_security: Bitte wähle ein Wertpapier aus oder gib eines ein + enter_qty_or_price: Bitte gib entweder Menge oder Preis pro Aktie ein. Der andere Wert wird aus dem Transaktionsbetrag berechnet. + invalid_qty_or_price: Ungültige Menge oder Preis. Bitte gib gültige positive Werte ein. + conversion_failed: "Transaktion konnte nicht umgewandelt werden: %{error}" + unexpected_error: "Unerwarteter Fehler bei der Umwandlung: %{error}" searches: filters: amount_filter: @@ -61,10 +168,15 @@ de: on_or_after: am oder nach %{date} on_or_before: am oder vor %{date} transfer: Überweisung + confirmed: Bestätigt + pending: Ausstehend type_filter: expense: Ausgabe income: Einnahme transfer: Überweisung + status_filter: + confirmed: Bestätigt + pending: Ausstehend menu: account_filter: Konto amount_filter: Betrag @@ -74,6 +186,7 @@ de: clear_filters: Filter löschen date_filter: Datum merchant_filter: Händler + status_filter: Status tag_filter: Tag type_filter: Typ search: diff --git a/config/locales/views/transactions/en.yml b/config/locales/views/transactions/en.yml index 02287d644..c5b5c3f0c 100644 --- a/config/locales/views/transactions/en.yml +++ b/config/locales/views/transactions/en.yml @@ -2,6 +2,9 @@ en: transactions: unknown_name: Unknown transaction + selection_bar: + duplicate: Duplicate + edit: Edit form: account: Account account_prompt: Select an Account @@ -13,6 +16,7 @@ en: description_placeholder: Describe transaction expense: Expense income: Income + merchant_label: Merchant none: (none) note_label: Notes note_placeholder: Enter a note @@ -31,6 +35,7 @@ en: balances, and cannot be undone. delete_title: Delete transaction details: Details + attachments: Attachments exclude: Exclude exclude_description: Excluded transactions will be removed from budgeting calculations and reports. activity_type: Activity Type @@ -40,6 +45,9 @@ en: convert_to_trade_title: Convert to Security Trade convert_to_trade_description: Convert this transaction into a Buy or Sell trade with security details for portfolio tracking. convert_to_trade_button: Convert to Trade + pending_duplicate_merger_title: Duplicate of Posted Transaction? + pending_duplicate_merger_description: Manually merge this pending transaction with its posted version. + pending_duplicate_merger_button: Open merger merchant_label: Merchant name_label: Name nature: Type @@ -73,21 +81,39 @@ en: potential_duplicate_description: This pending transaction may be the same as the posted transaction below. If so, merge them to avoid double-counting. merge_duplicate: Yes, merge them keep_both: No, keep both + split_parent_row: + split_label: "Split" transaction: pending: Pending pending_tooltip: Pending transaction — may change when posted - linked_with_plaid: Linked with Plaid + linked_with_provider: Linked with %{provider} activity_type_tooltip: Investment activity type possible_duplicate: Duplicate? potential_duplicate_tooltip: This may be a duplicate of another transaction review_recommended: Review review_recommended_tooltip: Large amount difference — review recommended to check if this is a duplicate + split: Split + split_tooltip: This transaction has been split into multiple entries + split_child_tooltip: Part of a split transaction merge_duplicate: success: Transactions merged successfully failure: Could not merge transactions dismiss_duplicate: success: Kept as separate transactions failure: Could not dismiss duplicate suggestion + pending_duplicate_merge: + possible_duplicate: Duplicate? + possible_duplicate_short: Dup? + review_recommended: Review + review_recommended_short: Rev + confirm_title: "Merge with posted transaction (%{posted_amount})" + reject_title: Keep as separate transactions + summary: + total_transactions: Total transactions + income: Income + expenses: Expenses + inflow: Inflow + outflow: Outflow header: edit_categories: Edit categories edit_imports: Edit imports @@ -98,6 +124,42 @@ en: transaction: transaction transactions: transactions import: Import + categorize_button: + one: "Categorize (1)" + other: "Categorize (%{count})" + categorizes: + show: + exit: "Exit" + skip: "Skip" + remaining: + one: "1 uncategorized transaction remaining" + other: "%{count} uncategorized transactions remaining" + transaction_count: + one: "1 transaction" + other: "%{count} transactions" + transactions_hint: "Uncheck to exclude a transaction, or assign it a different category directly in its row." + assign_category: "Assign a category" + assign_category_prompt: "→ assign" + filter_placeholder: "Search categories..." + col_transaction: "Transaction" + col_date: "Date" + col_amount: "Amount" + col_category: "Category" + type_income: "Income" + type_expense: "Expense" + create_rule_label: "Create Categorization Rule" + rule_description_prefix: "Future %{type} transactions with name containing" + rule_description_suffix: "should also get this category." + no_categories: "No matching categories" + all_done: "All transactions are categorized" + create: + categorized: + one: "1 transaction categorized" + other: "%{count} transactions categorized" + rule_creation_failed: "Transactions categorized, but the rule could not be created (it may already exist)." + entry_row: + include_checkbox: "Include %{name}" + assign_category_select: "Assign category for %{name}" list: drag_drop_title: Drop CSV to import drag_drop_subtitle: Upload transactions directly @@ -196,3 +258,21 @@ en: less_than: less than form: toggle_selection_checkboxes: Toggle all checkboxes + attachments: + cannot_exceed: "Cannot exceed %{count} attachments per transaction" + uploaded_one: "Attachment uploaded successfully" + uploaded_many: "%{count} attachments uploaded successfully" + failed_upload: "Failed to upload attachment: %{error}" + no_files_selected: "No files selected for upload" + attachment_deleted: "Attachment deleted successfully" + failed_delete: "Failed to delete attachment: %{error}" + upload_failed: "Failed to upload attachment. Please try again or contact support." + delete_failed: "Failed to delete attachment. Please try again or contact support." + upload: "Upload" + no_attachments: "No attachments yet" + select_up_to: "Select up to %{count} files (images or PDFs, max %{size}MB each) • %{used} of %{count} used" + files: + one: "File (1)" + other: "Files (%{count})" + browse_to_add: "Browse to add files" + max_reached: "Maximum file limit reached (%{count}/%{max}). Delete an existing file to upload another." diff --git a/config/locales/views/transactions/es.yml b/config/locales/views/transactions/es.yml index 03c01f78c..b5d294422 100644 --- a/config/locales/views/transactions/es.yml +++ b/config/locales/views/transactions/es.yml @@ -1,6 +1,7 @@ --- es: transactions: + unknown_name: Transacción desconocida form: account: Cuenta account_prompt: Selecciona una cuenta @@ -29,6 +30,15 @@ es: delete_subtitle: Esto eliminará permanentemente la transacción, afectará tus saldos históricos y no se podrá deshacer. delete_title: Eliminar transacción details: Detalles + exclude: Excluir + exclude_description: Las transacciones excluidas se eliminarán de los cálculos y presupuestos e informes. + activity_type: Tipo de actividad + activity_type_description: Tipo de actividad de inversión (Compra, Venta, Dividendo, etc.). Detectado automáticamente o configurado manualmente. + one_time_title: Transacción puntual %{type} + one_time_description: Las transacciones puntuales se excluirán de ciertos cálculos de presupuesto e informes para ayudarte a ver lo que es realmente importante. + convert_to_trade_title: Convertir en operación de valores + convert_to_trade_description: Convierte esta transacción en una operación de compra o venta con detalles del valor para el seguimiento de la cartera. + convert_to_trade_button: Convertir en operación merchant_label: Comerciante name_label: Nombre nature: Tipo @@ -38,7 +48,46 @@ es: overview: Resumen settings: Configuración tags_label: Etiquetas + tab_transactions: Transacciones + tab_upcoming: Próximas uncategorized: "(sin categorizar)" + activity_labels: + buy: Compra + sell: Venta + sweep_in: Transferencia de barrido (entrada) + sweep_out: Transferencia de barrido (salida) + dividend: Dividendo + reinvestment: Reinversión + interest: Interés + fee: Comisión + transfer: Transferencia + contribution: Aportación + withdrawal: Retirada + exchange: Intercambio + other: Otros + mark_recurring: Marcar como recurrente + mark_recurring_subtitle: Realiza el seguimiento como una transacción recurrente. La variación del importe se calcula automáticamente basándose en transacciones similares de los últimos 6 meses. + mark_recurring_title: Transacción recurrente + potential_duplicate_title: Posible duplicado detectado + potential_duplicate_description: Esta transacción pendiente podría ser la misma que la transacción confirmada a continuación. Si es así, combínalas para evitar una doble contabilización. + duplicate_resolution: + merge_duplicate: Sí, combinarlas + keep_both: No, mantener ambas + transaction: + pending: Pendiente + pending_tooltip: Transacción pendiente — puede cambiar al confirmarse + linked_with_provider: Vinculado con %{provider} + activity_type_tooltip: Tipo de actividad de inversión + possible_duplicate: ¿Duplicada? + potential_duplicate_tooltip: Esto puede ser un duplicado de otra transacción + review_recommended: Revisar + review_recommended_tooltip: Gran diferencia de importe — se recomienda revisar para comprobar si es un duplicado + merge_duplicate: + success: Transacciones combinadas correctamente + failure: No se pudieron combinar las transacciones + dismiss_duplicate: + success: Mantenidas como transacciones separadas + failure: No se pudo descartar la sugerencia de duplicado header: edit_categories: Editar categorías edit_imports: Editar importaciones @@ -48,6 +97,65 @@ es: index: transaction: transacción transactions: transacciones + import: Importar + list: + drag_drop_title: Suelta el CSV para importar + drag_drop_subtitle: Sube transacciones directamente + transaction: transacción + transactions: transacciones + toggle_recurring_section: Alternar próximas transacciones recurrentes + search: + filters: + account: Cuenta + date: Fecha + type: Tipo + status: Estado + amount: Importe + category: Categoría + tag: Etiqueta + merchant: Comerciante + convert_to_trade: + title: Convertir en operación de valores + description: Convierte esta transacción en una operación con detalles del valor + date_label: "Fecha:" + account_label: "Cuenta:" + amount_label: "Importe:" + security_label: Valor + security_prompt: Selecciona un valor... + security_custom: "+ Introducir ticker personalizado" + security_not_listed_hint: ¿No ves tu valor? Selecciona "Introducir ticker personalizado" al final de la lista. + ticker_placeholder: AAPL + ticker_hint: Introduce el símbolo del ticker de la acción/ETF (ej. AAPL, MSFT) + ticker_search_placeholder: Buscar un ticker... + ticker_search_hint: Busca por símbolo de ticker o nombre de empresa, o escribe un ticker personalizado + price_mismatch_title: Es posible que el precio no coincida + price_mismatch_message: "Tu precio (%{entered_price}/acción) difiere significativamente del precio de mercado actual de %{ticker} (%{market_price}). Si esto parece incorrecto, es posible que hayas seleccionado el valor equivocado — intenta usar \"Introducir ticker personalizado\" para especificar el correcto." + quantity_label: Cantidad (Acciones) + quantity_placeholder: ej. 20 + quantity_hint: Número de acciones negociadas + price_label: Precio por acción + price_placeholder: ej. 52.15 + price_hint: Precio por acción (%{currency}) + qty_or_price_hint: Introduce al menos la cantidad O el precio. El otro se calculará a partir del importe de la transacción (%{amount}). + trade_type_label: Tipo de operación + trade_type_hint: Comprar o vender acciones de un valor + exchange_label: Bolsa (Opcional) + exchange_placeholder: XNAS + exchange_hint: Deja en blanco para detectar automáticamente + cancel: Cancelar + submit: Convertir en operación + success: Transacción convertida en operación correctamente + conversion_note: "Convertido desde la transacción: %{original_name} (%{original_date})" + errors: + not_investment_account: Solo las transacciones en cuentas de inversión pueden convertirse en operaciones + already_converted: Esta transacción ya ha sido convertida o excluida + enter_ticker: Por favor, introduce un símbolo de ticker + security_not_found: El valor seleccionado ya no existe. Por favor, selecciona otro. + select_security: Por favor, selecciona o introduce un valor + enter_qty_or_price: Por favor, introduce la cantidad o el precio por acción. El otro se calculará a partir del importe de la transacción. + invalid_qty_or_price: Cantidad o precio no válidos. Por favor, introduce valores positivos válidos. + conversion_failed: "Error al convertir la transacción: %{error}" + unexpected_error: "Error inesperado durante la conversión: %{error}" searches: filters: amount_filter: @@ -61,10 +169,15 @@ es: on_or_after: en o después de %{date} on_or_before: en o antes de %{date} transfer: Transferencia + confirmed: Confirmada + pending: Pendiente type_filter: expense: Gasto income: Ingreso transfer: Transferencia + status_filter: + confirmed: Confirmada + pending: Pendiente menu: account_filter: Cuenta amount_filter: Importe @@ -74,6 +187,7 @@ es: clear_filters: Limpiar filtros date_filter: Fecha merchant_filter: Comerciante + status_filter: Estado tag_filter: Etiqueta type_filter: Tipo search: @@ -81,4 +195,4 @@ es: greater_than: mayor que less_than: menor que form: - toggle_selection_checkboxes: Alternar todas las casillas + toggle_selection_checkboxes: Alternar todas las casillas \ No newline at end of file diff --git a/config/locales/views/transactions/fr.yml b/config/locales/views/transactions/fr.yml index e4f6960e9..d6aeb52a3 100644 --- a/config/locales/views/transactions/fr.yml +++ b/config/locales/views/transactions/fr.yml @@ -45,6 +45,8 @@ fr: edit_merchants: Modifier les marchands edit_tags: Modifier les étiquettes import: Importer + transaction: + linked_with_provider: Lié avec %{provider} index: transaction: transaction transactions: transactions diff --git a/config/locales/views/transactions/nb.yml b/config/locales/views/transactions/nb.yml index 5661ad062..479659c30 100644 --- a/config/locales/views/transactions/nb.yml +++ b/config/locales/views/transactions/nb.yml @@ -46,6 +46,8 @@ nb: edit_merchants: Rediger selgere edit_tags: Rediger tagger import: Importer + transaction: + linked_with_provider: Koblet med %{provider} index: transaction: transaksjon transactions: transaksjoner diff --git a/config/locales/views/transactions/nl.yml b/config/locales/views/transactions/nl.yml index 4e3bb1dfc..25415e4fc 100644 --- a/config/locales/views/transactions/nl.yml +++ b/config/locales/views/transactions/nl.yml @@ -74,6 +74,7 @@ nl: transaction: pending: Wachtend pending_tooltip: Wachtende transactie — kan wijzigen bij posting + linked_with_provider: Gekoppeld met %{provider} activity_type_tooltip: Beleggingsactiviteitstype possible_duplicate: Duplicaat? potential_duplicate_tooltip: Dit kan een duplicaat zijn van een andere transactie diff --git a/config/locales/views/transactions/pl.yml b/config/locales/views/transactions/pl.yml new file mode 100644 index 000000000..9ae58f25e --- /dev/null +++ b/config/locales/views/transactions/pl.yml @@ -0,0 +1,242 @@ +--- +pl: + transactions: + unknown_name: Nieznana transakcja + selection_bar: + duplicate: Duplikat + edit: Edytuj + form: + account: Konto + account_prompt: Wybierz konto + amount: Kwota + category: Kategoria + category_prompt: Wybierz kategorię + date: Data + description: Opis + description_placeholder: Opisz transakcję + expense: Wydatek + income: Przychód + merchant_label: Kontrahent + none: "(brak)" + note_label: Notatki + note_placeholder: Wprowadź notatkę + submit: Dodaj transakcję + tags_label: Tagi + transfer: Przelew + new: + new_transaction: Nowa transakcja + show: + account_label: Konto + amount: Kwota + category_label: Kategoria + date_label: Data + delete: Usuń + delete_subtitle: To trwale usuwa transakcję, wpływa na historyczne salda i nie może zostać cofnięte. + delete_title: Usuń transakcję + details: Szczegóły + attachments: Załączniki + exclude: Wyklucz + exclude_description: Wykluczone transakcje zostaną usunięte z obliczeń budżetowych i raportów. + activity_type: Typ aktywności + activity_type_description: Typ aktywności inwestycyjnej (kupno, sprzedaż, dywidenda itp.). Wykrywany automatycznie lub ustawiany ręcznie. + one_time_title: Jednorazowe %{type} + one_time_description: Jednorazowe transakcje będą wykluczone z części obliczeń budżetowych i raportów, aby łatwiej było zobaczyć to, co naprawdę istotne. + convert_to_trade_title: Zamień na transakcję papieru wartościowego + convert_to_trade_description: Zamień tę transakcję na zlecenie kupna lub sprzedaży z danymi papieru wartościowego do śledzenia portfela. + convert_to_trade_button: Zamień na transakcję giełdową + pending_duplicate_merger_title: Duplikat zaksięgowanej transakcji? + pending_duplicate_merger_description: Ręcznie połącz tę oczekującą transakcję z jej zaksięgowaną wersją. + pending_duplicate_merger_button: Otwórz łączenie + merchant_label: Kontrahent + name_label: Nazwa + nature: Typ + none: "(brak)" + note_label: Notatki + note_placeholder: Wprowadź notatkę + overview: Przegląd + settings: Ustawienia + tags_label: Tagi + tab_transactions: Transakcje + tab_upcoming: Nadchodzące + uncategorized: "(bez kategorii)" + activity_labels: + buy: Kupno + sell: Sprzedaż + sweep_in: Transfer przychodzący + sweep_out: Transfer wychodzący + dividend: Dywidenda + reinvestment: Reinwestycja + interest: Odsetki + fee: Opłata + transfer: Przelew + contribution: Wpłata + withdrawal: Wypłata + exchange: Wymiana + other: Inne + mark_recurring: Oznacz jako cykliczną + mark_recurring_subtitle: Śledź to jako transakcję cykliczną. Różnica kwoty jest automatycznie obliczana na podstawie ostatnich 6 miesięcy podobnych transakcji. + mark_recurring_title: Transakcja cykliczna + potential_duplicate_title: Wykryto możliwy duplikat + potential_duplicate_description: Ta oczekująca transakcja może być tą samą transakcją co zaksięgowana transakcja poniżej. Jeśli tak, połącz je, aby uniknąć podwójnego liczenia. + merge_duplicate: + success: Pomyślnie połączono transakcje + failure: Nie udało się połączyć transakcji + keep_both: Nie, zachowaj obie + split_parent_row: + split_label: Podział + transaction: + pending: Oczekująca + pending_tooltip: Transakcja oczekująca — może się zmienić po zaksięgowaniu + linked_with_provider: Połączono z %{provider} + activity_type_tooltip: Typ aktywności inwestycyjnej + possible_duplicate: Duplikat? + potential_duplicate_tooltip: To może być duplikat innej transakcji + review_recommended: Sprawdź + review_recommended_tooltip: Duża różnica kwoty — zalecana weryfikacja, aby sprawdzić, czy to duplikat + split: Podział + split_tooltip: Ta transakcja została podzielona na wiele wpisów + split_child_tooltip: Część transakcji dzielonej + dismiss_duplicate: + success: Pozostawiono jako oddzielne transakcje + failure: Nie udało się odrzucić sugestii duplikatu + pending_duplicate_merge: + possible_duplicate: Duplikat? + possible_duplicate_short: Dupl.? + review_recommended: Sprawdź + review_recommended_short: Weryf. + confirm_title: Połącz z zaksięgowaną transakcją (%{posted_amount}) + reject_title: Zachowaj jako oddzielne transakcje + summary: + total_transactions: Łącznie transakcji + income: Przychody + expenses: Wydatki + inflow: Wpływy + outflow: Wypływy + header: + edit_categories: Edytuj kategorie + edit_imports: Edytuj importy + edit_merchants: Edytuj kontrahentów + edit_tags: Edytuj tagi + import: Importuj + index: + transaction: transakcja + transactions: transakcje + import: Importuj + list: + drag_drop_title: Upuść plik CSV, aby zaimportować + drag_drop_subtitle: Prześlij transakcje bezpośrednio + transaction: transakcja + transactions: transakcje + toggle_recurring_section: Przełącz nadchodzące transakcje cykliczne + search: + filters: + account: Konto + date: Data + type: Typ + status: Status + amount: Kwota + category: Kategoria + tag: Tag + merchant: Kontrahent + convert_to_trade: + title: Zamień na transakcję papieru wartościowego + description: Zamień tę transakcję na transakcję giełdową z danymi papieru wartościowego + date_label: 'Data:' + account_label: 'Konto:' + amount_label: 'Kwota:' + security_label: Papier wartościowy + security_prompt: Wybierz papier wartościowy... + security_custom: "+ Wprowadź własny ticker" + security_not_listed_hint: Nie widzisz swojego papieru? Wybierz na dole listy opcję „Wprowadź własny ticker”. + ticker_placeholder: AAPL + ticker_hint: Wprowadź symbol giełdowy akcji lub ETF-u, np. AAPL lub MSFT + ticker_search_placeholder: Szukaj tickera... + ticker_search_hint: Szukaj po symbolu, nazwie spółki lub wpisz własny ticker + price_mismatch_title: Cena może się nie zgadzać + price_mismatch_message: Twoja cena (%{entered_price}/akcję) znacznie różni się od bieżącej ceny rynkowej %{ticker} (%{market_price}). Jeśli to wygląda nieprawidłowo, mogłeś wybrać zły papier wartościowy — spróbuj użyć opcji „Wprowadź własny ticker”, aby wskazać poprawny. + quantity_label: Ilość (akcji) + quantity_placeholder: np. 20 + quantity_hint: Liczba akcji objętych transakcją + price_label: Cena za akcję + price_placeholder: np. 52.15 + price_hint: Cena za akcję (%{currency}) + qty_or_price_hint: Wprowadź przynajmniej ilość albo cenę. Druga wartość zostanie obliczona z kwoty transakcji (%{amount}). + trade_type_label: Typ transakcji giełdowej + trade_type_hint: Kupno lub sprzedaż akcji papieru wartościowego + exchange_label: Giełda (opcjonalne) + exchange_placeholder: XNAS + exchange_hint: Pozostaw puste, aby wykryć automatycznie + cancel: Anuluj + submit: Zamień na transakcję giełdową + success: Transakcja została zamieniona na transakcję giełdową + conversion_note: 'Przekształcono z transakcji: %{original_name} (%{original_date})' + errors: + not_investment_account: Tylko transakcje na kontach inwestycyjnych można zamieniać na transakcje giełdowe + already_converted: Ta transakcja została już przekształcona albo wykluczona + enter_ticker: Wprowadź symbol tickera + security_not_found: Wybrany papier wartościowy już nie istnieje. Wybierz inny. + select_security: Wybierz albo wpisz papier wartościowy + enter_qty_or_price: Wprowadź ilość albo cenę za akcję. Druga wartość zostanie obliczona z kwoty transakcji. + invalid_qty_or_price: Nieprawidłowa ilość lub cena. Wprowadź poprawne dodatnie wartości. + conversion_failed: 'Nie udało się przekształcić transakcji: %{error}' + unexpected_error: 'Wystąpił nieoczekiwany błąd podczas przekształcania: %{error}' + searches: + filters: + amount_filter: + equal_to: Równe + greater_than: Większe niż + less_than: Mniejsze niż + placeholder: '0' + badge: + expense: Wydatek + income: Przychód + on_or_after: od %{date} + on_or_before: do %{date} + transfer: Przelew + confirmed: Potwierdzone + pending: Oczekujące + type_filter: + expense: Wydatek + income: Przychód + transfer: Przelew + status_filter: + confirmed: Potwierdzone + pending: Oczekujące + menu: + account_filter: Konto + amount_filter: Kwota + apply: Zastosuj + cancel: Anuluj + category_filter: Kategoria + clear_filters: Wyczyść filtry + date_filter: Data + merchant_filter: Kontrahent + status_filter: Status + tag_filter: Tag + type_filter: Typ + search: + equal_to: równe + greater_than: większe niż + less_than: mniejsze niż + form: + toggle_selection_checkboxes: Przełącz wszystkie pola wyboru + attachments: + cannot_exceed: Nie można przekroczyć %{count} załączników na transakcję + uploaded_one: Załącznik został pomyślnie przesłany + uploaded_many: "Pomyślnie przesłano %{count} załączników" + failed_upload: 'Nie udało się przesłać załącznika: %{error}' + no_files_selected: Nie wybrano plików do przesłania + attachment_deleted: Załącznik został pomyślnie usunięty + failed_delete: 'Nie udało się usunąć załącznika: %{error}' + upload_failed: Nie udało się przesłać załącznika. Spróbuj ponownie lub skontaktuj się ze wsparciem. + delete_failed: Nie udało się usunąć załącznika. Spróbuj ponownie lub skontaktuj się ze wsparciem. + upload: Prześlij + no_attachments: Brak załączników + select_up_to: Wybierz maksymalnie %{count} plików (obrazy lub PDF, maks. %{size} MB każdy) • wykorzystano %{used} z %{count} + files: + one: Plik (1) + few: Pliki (%{count}) + many: Plików (%{count}) + other: Plików (%{count}) + browse_to_add: Przeglądaj, aby dodać pliki + max_reached: Osiągnięto maksymalny limit plików (%{count}/%{max}). Usuń istniejący plik, aby przesłać kolejny. diff --git a/config/locales/views/transactions/pt-BR.yml b/config/locales/views/transactions/pt-BR.yml index 15476c952..5d898a27a 100644 --- a/config/locales/views/transactions/pt-BR.yml +++ b/config/locales/views/transactions/pt-BR.yml @@ -49,6 +49,8 @@ pt-BR: edit_merchants: Editar comerciantes edit_tags: Editar tags import: Importar + transaction: + linked_with_provider: Vinculado com %{provider} index: transaction: transação transactions: transações diff --git a/config/locales/views/transactions/ro.yml b/config/locales/views/transactions/ro.yml index 8312d3406..dd0d71087 100644 --- a/config/locales/views/transactions/ro.yml +++ b/config/locales/views/transactions/ro.yml @@ -45,6 +45,8 @@ ro: edit_merchants: Editează comercianți edit_tags: Editează etichete import: Importă + transaction: + linked_with_provider: Conectat cu %{provider} index: transaction: tranzacție transactions: tranzacții diff --git a/config/locales/views/transactions/tr.yml b/config/locales/views/transactions/tr.yml index b7f545a8e..bb27ce92c 100644 --- a/config/locales/views/transactions/tr.yml +++ b/config/locales/views/transactions/tr.yml @@ -45,6 +45,8 @@ tr: edit_merchants: Satıcıları düzenle edit_tags: Etiketleri düzenle import: İçe aktar + transaction: + linked_with_provider: "%{provider} ile bağlantılı" index: transaction: işlem transactions: işlemler diff --git a/config/locales/views/transactions/zh-CN.yml b/config/locales/views/transactions/zh-CN.yml index b8533bba2..11fa2e3e5 100644 --- a/config/locales/views/transactions/zh-CN.yml +++ b/config/locales/views/transactions/zh-CN.yml @@ -25,6 +25,8 @@ zh-CN: edit_merchants: 编辑商户 edit_tags: 编辑标签 import: 导入 + transaction: + linked_with_provider: 已与 %{provider} 关联 index: import: 导入 transaction: 交易 diff --git a/config/locales/views/transactions/zh-TW.yml b/config/locales/views/transactions/zh-TW.yml index d5bb0fb46..53c080a8e 100644 --- a/config/locales/views/transactions/zh-TW.yml +++ b/config/locales/views/transactions/zh-TW.yml @@ -48,6 +48,8 @@ zh-TW: edit_merchants: 編輯商家 edit_tags: 編輯標籤 import: 匯入 + transaction: + linked_with_provider: 已與 %{provider} 連結 index: transaction: 筆交易 transactions: 筆交易 diff --git a/config/locales/views/transfers/pl.yml b/config/locales/views/transfers/pl.yml new file mode 100644 index 000000000..8f01f23ad --- /dev/null +++ b/config/locales/views/transfers/pl.yml @@ -0,0 +1,30 @@ +--- +pl: + transfers: + create: + success: Przelew został utworzony + destroy: + success: Przelew został usunięty + form: + amount: Kwota + date: Data + expense: Wydatek + from: Z + income: Przychód + select_account: Wybierz konto + submit: Utwórz przelew + to: Do + transfer: Przelew + new: + title: Nowy przelew + show: + delete: Usuń przelew + delete_subtitle: Ta operacja usuwa przelew. Nie usunie transakcji źródłowych. + delete_title: Usunąć przelew? + details: Szczegóły + note_label: Notatki + note_placeholder: Dodaj notatkę do tego przelewu + overview: Przegląd + settings: Ustawienia + update: + success: Przelew został zaktualizowany diff --git a/config/locales/views/users/es.yml b/config/locales/views/users/es.yml index 54ef054d3..da6fb7cd9 100644 --- a/config/locales/views/users/es.yml +++ b/config/locales/views/users/es.yml @@ -8,6 +8,9 @@ es: email_change_initiated: Por favor, revisa tu nueva dirección de correo electrónico para obtener instrucciones de confirmación. success: Tu perfil ha sido actualizado. + resend_confirmation_email: + success: Se ha puesto en cola el envío de un nuevo correo de confirmación. + no_pending_change: ¡No hay ningún cambio de correo electrónico pendiente en este momento! reset: success: Tu cuenta ha sido restablecida. Los datos se eliminarán en segundo plano en algún momento. unauthorized: No estás autorizado para realizar esta acción. diff --git a/config/locales/views/users/pl.yml b/config/locales/views/users/pl.yml new file mode 100644 index 000000000..d4d9690f7 --- /dev/null +++ b/config/locales/views/users/pl.yml @@ -0,0 +1,17 @@ +--- +pl: + users: + destroy: + success: Twoje konto zostało usunięte. + update: + email_change_failed: Nie udało się zmienić adresu e-mail. + email_change_initiated: Sprawdź nowy adres e-mail, aby dokończyć potwierdzenie zmiany. + success: Twój profil został zaktualizowany. + resend_confirmation_email: + success: Nowa wiadomość potwierdzająca została dodana do kolejki wysyłki. + no_pending_change: Brak oczekującej zmiany adresu e-mail! + reset: + success: Twoje konto zostało zresetowane. Dane zostaną usunięte w tle za jakiś czas. + unauthorized: Nie masz uprawnień do wykonania tej akcji + reset_with_sample_data: + success: Twoje konto zostało zresetowane, a dane przykładowe są przygotowywane. Dane demo pojawią się wkrótce. diff --git a/config/locales/views/valuations/pl.yml b/config/locales/views/valuations/pl.yml new file mode 100644 index 000000000..b99fa06ff --- /dev/null +++ b/config/locales/views/valuations/pl.yml @@ -0,0 +1,30 @@ +--- +pl: + valuations: + form: + amount: Kwota + submit: Dodaj aktualizację salda + header: + balance: Saldo + index: + change: zmiana + date: data + new_entry: Nowy wpis + no_valuations: Brak wycen dla tego konta + valuations: Wartość + value: wartość + new: + title: Nowe saldo + show: + amount: Kwota + date_label: Data + delete: Usuń + delete_subtitle: Tej akcji nie można cofnąć + delete_title: Usuń wpis + details: Szczegóły + name_label: Nazwa + name_placeholder: Wprowadź nazwę tego wpisu + note_label: Notatki + note_placeholder: Dodaj dodatkowe szczegóły dotyczące tego wpisu + overview: Przegląd + settings: Ustawienia diff --git a/config/locales/views/vehicles/pl.yml b/config/locales/views/vehicles/pl.yml new file mode 100644 index 000000000..1f53f8d63 --- /dev/null +++ b/config/locales/views/vehicles/pl.yml @@ -0,0 +1,25 @@ +--- +pl: + vehicles: + edit: + edit: Edytuj %{account} + form: + make: Marka + make_placeholder: Toyota + mileage: Przebieg + mileage_placeholder: '15000' + mileage_unit: Jednostka + model: Model + model_placeholder: Camry + year: Rok + year_placeholder: '2023' + new: + title: Wprowadź dane pojazdu + overview: + current_price: Aktualna cena + make_model: Marka i model + mileage: Przebieg + purchase_price: Cena zakupu + trend: Trend + unknown: Nieznane + year: Rok diff --git a/config/routes.rb b/config/routes.rb index fd0e77665..35ca76559 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -49,6 +49,21 @@ Rails.application.routes.draw do end end + resources :binance_items, only: [ :index, :new, :create, :show, :edit, :update, :destroy ] do + collection do + get :select_accounts + post :link_accounts + get :select_existing_account + post :link_existing_account + end + + member do + post :sync + get :setup_accounts + post :complete_account_setup + end + end + resources :snaptrade_items, only: [ :index, :new, :create, :show, :edit, :update, :destroy ] do collection do get :preload_accounts @@ -74,6 +89,7 @@ Rails.application.routes.draw do resources :coinstats_items, only: [ :index, :new, :create, :update, :destroy ] do collection do post :link_wallet + post :link_exchange end member do post :sync @@ -107,6 +123,11 @@ Rails.application.routes.draw do mount Lookbook::Engine, at: "/design-system" + if Rails.env.development? + mount Rswag::Api::Engine => "/api-docs" + mount Rswag::Ui::Engine => "/api-docs" + end + # Uses basic auth - see config/initializers/sidekiq.rb mount Sidekiq::Web => "/sidekiq" @@ -125,6 +146,8 @@ Rails.application.routes.draw do end end + get "exports/archive/:token", to: "archived_exports#show", as: :archived_export + get "changelog", to: "pages#changelog" get "feedback", to: "pages#feedback" patch "dashboard/preferences", to: "pages#update_preferences" @@ -165,8 +188,10 @@ Rails.application.routes.draw do namespace :settings do resource :profile, only: [ :show, :destroy ] resource :preferences, only: :show + resource :appearance, only: %i[show update] resource :hosting, only: %i[show update] do delete :clear_cache, on: :collection + delete :disconnect_external_assistant, on: :collection end resource :payment, only: :show resource :security, only: :show @@ -210,6 +235,7 @@ Rails.application.routes.draw do end resources :budgets, only: %i[index show edit update], param: :month_year do + post :copy_previous, on: :member get :picker, on: :collection resources :budget_categories, only: %i[index show update] @@ -226,6 +252,7 @@ Rails.application.routes.draw do collection do get :merge post :perform_merge + post :enhance end end @@ -242,6 +269,7 @@ Rails.application.routes.draw do resource :configuration, only: %i[show update], module: :import resource :clean, only: :show, module: :import resource :confirm, only: :show, module: :import + resource :qif_category_selection, only: %i[show update], module: :import resources :rows, only: %i[show update], module: :import resources :mappings, only: :update, module: :import @@ -252,6 +280,7 @@ Rails.application.routes.draw do post :unlock_cost_basis patch :remap_security post :reset_security + post :sync_prices end end resources :trades, only: %i[show new create update destroy] do @@ -267,11 +296,18 @@ Rails.application.routes.draw do namespace :transactions do resource :bulk_deletion, only: :create resource :bulk_update, only: %i[new create] + resource :categorize, only: %i[show create] do + patch :assign_entry, on: :collection + get :preview_rule, on: :collection + end end resources :transactions, only: %i[index new create show update destroy] do + resource :split, only: %i[new create edit update destroy] resource :transfer_match, only: %i[new create] + resource :pending_duplicate_merges, only: %i[new create] resource :category, only: :update, controller: :transaction_categories + resources :attachments, only: %i[show create destroy], controller: :transaction_attachments collection do delete :clear_filter @@ -329,6 +365,8 @@ Rails.application.routes.draw do post :sync get :sparkline patch :toggle_active + patch :set_default + patch :remove_default get :select_provider get :confirm_unlink delete :unlink @@ -337,6 +375,8 @@ Rails.application.routes.draw do collection do post :sync_all end + + resource :sharing, only: [ :show, :update ], controller: "account_sharings" end # Convenience routes for polymorphic paths @@ -379,6 +419,8 @@ Rails.application.routes.draw do post "auth/login", to: "auth#login" post "auth/refresh", to: "auth#refresh" post "auth/sso_exchange", to: "auth#sso_exchange" + post "auth/sso_link", to: "auth#sso_link" + post "auth/sso_create_account", to: "auth#sso_create_account" patch "auth/enable_ai", to: "auth#enable_ai" # Production API endpoints @@ -393,6 +435,7 @@ Rails.application.routes.draw do resources :valuations, only: [ :create, :update, :show ] resources :imports, only: [ :index, :show, :create ] resource :usage, only: [ :show ], controller: :usage + resource :balance_sheet, only: [ :show ], controller: :balance_sheet post :sync, to: "sync#create" resources :chats, only: [ :index, :show, :create, :update, :destroy ] do @@ -507,6 +550,12 @@ Rails.application.routes.draw do end end resources :users, only: [ :index, :update ] + resources :invitations, only: [ :destroy ] + resources :families, only: [] do + member do + delete :invitations, to: "invitations#destroy_all" + end + end end # Defines the root path route ("/") diff --git a/config/schedule.yml b/config/schedule.yml index c0d324408..c3903a229 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -29,4 +29,16 @@ clean_data: cron: "0 3 * * *" # daily at 3:00 AM class: "DataCleanerJob" queue: "scheduled" - description: "Cleans up old data (e.g., expired merchant associations)" + description: "Cleans up old data (e.g., expired merchant associations, expired archived exports)" + +clean_inactive_families: + cron: "0 4 * * *" # daily at 4:00 AM + class: "InactiveFamilyCleanerJob" + queue: "scheduled" + description: "Archives and destroys families that expired their trial without subscribing (managed mode only)" + +refresh_demo_family: + cron: "0 5 * * *" # daily at 5:00 AM UTC + class: "DemoFamilyRefreshJob" + queue: "scheduled" + description: "Refreshes demo family data and emails super admins with daily usage summary" diff --git a/db/migrate/20260219190000_scope_mercury_account_uniqueness_to_item.rb b/db/migrate/20260219190000_scope_mercury_account_uniqueness_to_item.rb new file mode 100644 index 000000000..e60147588 --- /dev/null +++ b/db/migrate/20260219190000_scope_mercury_account_uniqueness_to_item.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class ScopeMercuryAccountUniquenessToItem < ActiveRecord::Migration[7.2] + def up + # Allow the same Mercury account_id to be linked by different families (different mercury_items). + # Uniqueness is scoped per mercury_item, mirroring simplefin_accounts. + remove_index :mercury_accounts, name: "index_mercury_accounts_on_account_id", if_exists: true + unless index_exists?(:mercury_accounts, [ :mercury_item_id, :account_id ], unique: true, name: "index_mercury_accounts_on_item_and_account_id") + add_index :mercury_accounts, + [ :mercury_item_id, :account_id ], + unique: true, + name: "index_mercury_accounts_on_item_and_account_id" + end + end + + def down + if MercuryAccount.group(:account_id).having("COUNT(*) > 1").exists? + raise ActiveRecord::IrreversibleMigration, + "Cannot restore global unique index on mercury_accounts.account_id: " \ + "duplicate account_id values exist across mercury_items. " \ + "Remove duplicates first before rolling back." + end + + remove_index :mercury_accounts, name: "index_mercury_accounts_on_item_and_account_id", if_exists: true + unless index_exists?(:mercury_accounts, :account_id, name: "index_mercury_accounts_on_account_id") + add_index :mercury_accounts, :account_id, name: "index_mercury_accounts_on_account_id", unique: true + end + end +end diff --git a/db/migrate/20260219200001_scope_plaid_item_uniqueness.rb b/db/migrate/20260219200001_scope_plaid_item_uniqueness.rb new file mode 100644 index 000000000..53c9e2a11 --- /dev/null +++ b/db/migrate/20260219200001_scope_plaid_item_uniqueness.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# Scope plaid_accounts uniqueness to plaid_item so the same external account +# can be linked in multiple families. See: https://github.com/we-promise/sure/issues/740 +# Class name avoids "Account" to prevent secret-scanner false positive (AWS Access ID pattern) +class ScopePlaidItemUniqueness < ActiveRecord::Migration[7.2] + def up + remove_index :plaid_accounts, name: "index_plaid_accounts_on_plaid_id", if_exists: true + return if index_exists?(:plaid_accounts, [ :plaid_item_id, :plaid_id ], unique: true, name: "index_plaid_accounts_on_item_and_plaid_id") + + add_index :plaid_accounts, + [ :plaid_item_id, :plaid_id ], + unique: true, + name: "index_plaid_accounts_on_item_and_plaid_id" + end + + def down + if execute("SELECT 1 FROM plaid_accounts WHERE plaid_id IS NOT NULL GROUP BY plaid_id HAVING COUNT(DISTINCT plaid_item_id) > 1 LIMIT 1").any? + raise ActiveRecord::IrreversibleMigration, + "Cannot rollback: cross-item duplicates exist in plaid_accounts. Remove duplicates first." + end + + remove_index :plaid_accounts, name: "index_plaid_accounts_on_item_and_plaid_id", if_exists: true + return if index_exists?(:plaid_accounts, :plaid_id, name: "index_plaid_accounts_on_plaid_id") + + add_index :plaid_accounts, :plaid_id, name: "index_plaid_accounts_on_plaid_id", unique: true + end +end + +# Backwards-compatible alias for environments that may still reference the +# original migration constant derived from the old filename. +ScopePlaidAccountUniquenessToItem = ScopePlaidItemUniqueness unless defined?(ScopePlaidAccountUniquenessToItem) diff --git a/db/migrate/20260219200002_scope_indexa_capital_account_uniqueness_to_item.rb b/db/migrate/20260219200002_scope_indexa_capital_account_uniqueness_to_item.rb new file mode 100644 index 000000000..864aa96ef --- /dev/null +++ b/db/migrate/20260219200002_scope_indexa_capital_account_uniqueness_to_item.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +# Scope indexa_capital_accounts uniqueness to indexa_capital_item so the same +# external account can be linked in multiple families. See: https://github.com/we-promise/sure/issues/740 +class ScopeIndexaCapitalAccountUniquenessToItem < ActiveRecord::Migration[7.2] + def up + remove_index :indexa_capital_accounts, name: "index_indexa_capital_accounts_on_indexa_capital_account_id", if_exists: true + return if index_exists?(:indexa_capital_accounts, [ :indexa_capital_item_id, :indexa_capital_account_id ], unique: true, name: "index_indexa_capital_accounts_on_item_and_account_id") + + add_index :indexa_capital_accounts, + [ :indexa_capital_item_id, :indexa_capital_account_id ], + unique: true, + name: "index_indexa_capital_accounts_on_item_and_account_id", + where: "indexa_capital_account_id IS NOT NULL" + end + + def down + if execute("SELECT 1 FROM indexa_capital_accounts WHERE indexa_capital_account_id IS NOT NULL GROUP BY indexa_capital_account_id HAVING COUNT(DISTINCT indexa_capital_item_id) > 1 LIMIT 1").any? + raise ActiveRecord::IrreversibleMigration, + "Cannot rollback: cross-item duplicates exist in indexa_capital_accounts. Remove duplicates first." + end + + remove_index :indexa_capital_accounts, name: "index_indexa_capital_accounts_on_item_and_account_id", if_exists: true + return if index_exists?(:indexa_capital_accounts, :indexa_capital_account_id, name: "index_indexa_capital_accounts_on_indexa_capital_account_id") + + add_index :indexa_capital_accounts, :indexa_capital_account_id, + name: "index_indexa_capital_accounts_on_indexa_capital_account_id", + unique: true, + where: "indexa_capital_account_id IS NOT NULL" + end +end diff --git a/db/migrate/20260219200003_scope_snaptrade_account_uniqueness_to_item.rb b/db/migrate/20260219200003_scope_snaptrade_account_uniqueness_to_item.rb new file mode 100644 index 000000000..2413e2203 --- /dev/null +++ b/db/migrate/20260219200003_scope_snaptrade_account_uniqueness_to_item.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# Scope snaptrade_accounts uniqueness to snaptrade_item so the same external +# account can be linked in multiple families. See: https://github.com/we-promise/sure/issues/740 +class ScopeSnaptradeAccountUniquenessToItem < ActiveRecord::Migration[7.2] + def up + remove_index :snaptrade_accounts, name: "index_snaptrade_accounts_on_snaptrade_account_id", if_exists: true + + unless index_exists?(:snaptrade_accounts, [ :snaptrade_item_id, :snaptrade_account_id ], unique: true, name: "index_snaptrade_accounts_on_item_and_snaptrade_account_id") + add_index :snaptrade_accounts, + [ :snaptrade_item_id, :snaptrade_account_id ], + unique: true, + name: "index_snaptrade_accounts_on_item_and_snaptrade_account_id", + where: "snaptrade_account_id IS NOT NULL" + end + end + + def down + if execute("SELECT 1 FROM snaptrade_accounts WHERE snaptrade_account_id IS NOT NULL GROUP BY snaptrade_account_id HAVING COUNT(DISTINCT snaptrade_item_id) > 1 LIMIT 1").any? + raise ActiveRecord::IrreversibleMigration, + "Cannot rollback: cross-item duplicates exist in snaptrade_accounts. Remove duplicates first." + end + + remove_index :snaptrade_accounts, name: "index_snaptrade_accounts_on_item_and_snaptrade_account_id", if_exists: true + unless index_exists?(:snaptrade_accounts, :snaptrade_account_id, name: "index_snaptrade_accounts_on_snaptrade_account_id") + add_index :snaptrade_accounts, :snaptrade_account_id, + name: "index_snaptrade_accounts_on_snaptrade_account_id", + unique: true, + where: "snaptrade_account_id IS NOT NULL" + end + end +end diff --git a/db/migrate/20260219200004_scope_coinbase_account_uniqueness_to_item.rb b/db/migrate/20260219200004_scope_coinbase_account_uniqueness_to_item.rb new file mode 100644 index 000000000..3f77d92c2 --- /dev/null +++ b/db/migrate/20260219200004_scope_coinbase_account_uniqueness_to_item.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# NEW constraint: add per-item unique index on coinbase_accounts. Unlike Plaid/Snaptrade, +# there was no prior unique index—this can fail if existing data has duplicate +# (coinbase_item_id, account_id) pairs. See: https://github.com/we-promise/sure/issues/740 +class ScopeCoinbaseAccountUniquenessToItem < ActiveRecord::Migration[7.2] + def up + return if index_exists?(:coinbase_accounts, [ :coinbase_item_id, :account_id ], unique: true, name: "index_coinbase_accounts_on_item_and_account_id") + + if execute("SELECT 1 FROM coinbase_accounts WHERE account_id IS NOT NULL GROUP BY coinbase_item_id, account_id HAVING COUNT(*) > 1 LIMIT 1").any? + raise ActiveRecord::Migration::IrreversibleMigration, + "Duplicate (coinbase_item_id, account_id) pairs exist in coinbase_accounts. Resolve duplicates before running this migration." + end + + add_index :coinbase_accounts, + [ :coinbase_item_id, :account_id ], + unique: true, + name: "index_coinbase_accounts_on_item_and_account_id", + where: "account_id IS NOT NULL" + end + + def down + remove_index :coinbase_accounts, name: "index_coinbase_accounts_on_item_and_account_id", if_exists: true + end +end diff --git a/db/migrate/20260219200006_scope_lunchflow_account_uniqueness_to_item.rb b/db/migrate/20260219200006_scope_lunchflow_account_uniqueness_to_item.rb new file mode 100644 index 000000000..236a29500 --- /dev/null +++ b/db/migrate/20260219200006_scope_lunchflow_account_uniqueness_to_item.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# NEW constraint: add per-item unique index on lunchflow_accounts. Unlike Plaid/Snaptrade, +# there was no prior unique index—this can fail if existing data has duplicate +# (lunchflow_item_id, account_id) pairs. See: https://github.com/we-promise/sure/issues/740 +class ScopeLunchflowAccountUniquenessToItem < ActiveRecord::Migration[7.2] + def up + return if index_exists?(:lunchflow_accounts, [ :lunchflow_item_id, :account_id ], unique: true, name: "index_lunchflow_accounts_on_item_and_account_id") + + if execute("SELECT 1 FROM lunchflow_accounts WHERE account_id IS NOT NULL GROUP BY lunchflow_item_id, account_id HAVING COUNT(*) > 1 LIMIT 1").any? + raise ActiveRecord::Migration::IrreversibleMigration, + "Duplicate (lunchflow_item_id, account_id) pairs exist in lunchflow_accounts. Resolve duplicates before running this migration." + end + + add_index :lunchflow_accounts, + [ :lunchflow_item_id, :account_id ], + unique: true, + name: "index_lunchflow_accounts_on_item_and_account_id", + where: "account_id IS NOT NULL" + end + + def down + remove_index :lunchflow_accounts, name: "index_lunchflow_accounts_on_item_and_account_id", if_exists: true + end +end diff --git a/db/migrate/20260303120000_backfill_investment_contribution_categories.rb b/db/migrate/20260303120000_backfill_investment_contribution_categories.rb new file mode 100644 index 000000000..8a010d8b4 --- /dev/null +++ b/db/migrate/20260303120000_backfill_investment_contribution_categories.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +class BackfillInvestmentContributionCategories < ActiveRecord::Migration[7.2] + def up + # PR #924 fixed auto-categorization of investment contributions going forward, + # but transfers created before that PR have kind = 'investment_contribution' + # with category_id NULL. This backfill assigns the correct category to those + # transactions using the family's existing "Investment Contributions" category. + # + # Safety: + # - Only updates transactions where category_id IS NULL (never overwrites user choices) + # - Only updates transactions that already have kind = 'investment_contribution' + # - Skips families that don't have an Investment Contributions category yet + # (it will be lazily created on their next new transfer) + # - If a family has duplicate locale-variant categories, picks the oldest one + # (matches Family#investment_contributions_category dedup behavior) + + # Static snapshot of Category.all_investment_contributions_names at migration time. + # Inlined to avoid coupling to app code that may change after this migration ships. + locale_names = [ + "Investment Contributions", + "Contributions aux investissements", + "Contribucions d'inversió", + "Investeringsbijdragen" + ] + + quoted_names = locale_names.map { |n| connection.quote(n) }.join(", ") + + say_with_time "Backfilling category for investment_contribution transactions" do + execute <<-SQL.squish + UPDATE transactions + SET category_id = matched_category.id + FROM entries, accounts, + LATERAL ( + SELECT c.id + FROM categories c + WHERE c.family_id = accounts.family_id + AND c.name IN (#{quoted_names}) + ORDER BY c.created_at ASC + LIMIT 1 + ) AS matched_category + WHERE transactions.kind = 'investment_contribution' + AND transactions.category_id IS NULL + AND entries.entryable_id = transactions.id + AND entries.entryable_type = 'Transaction' + AND accounts.id = entries.account_id + SQL + end + end + + def down + # No-op: we cannot distinguish backfilled records from ones that were + # categorized at creation time, so reverting would incorrectly clear + # legitimately assigned categories. + end +end diff --git a/db/migrate/20260305120000_add_default_account_to_users.rb b/db/migrate/20260305120000_add_default_account_to_users.rb new file mode 100644 index 000000000..9bb6d0619 --- /dev/null +++ b/db/migrate/20260305120000_add_default_account_to_users.rb @@ -0,0 +1,5 @@ +class AddDefaultAccountToUsers < ActiveRecord::Migration[7.2] + def change + add_reference :users, :default_account, type: :uuid, foreign_key: { to_table: :accounts, on_delete: :nullify }, null: true + end +end diff --git a/db/migrate/20260308113006_remove_classification_from_categories.rb b/db/migrate/20260308113006_remove_classification_from_categories.rb new file mode 100644 index 000000000..b2862fc29 --- /dev/null +++ b/db/migrate/20260308113006_remove_classification_from_categories.rb @@ -0,0 +1,9 @@ +class RemoveClassificationFromCategories < ActiveRecord::Migration[7.2] + def up + rename_column :categories, :classification, :classification_unused + end + + def down + rename_column :categories, :classification_unused, :classification + end +end diff --git a/db/migrate/20260314120000_remove_unique_email_family_index_from_invitations.rb b/db/migrate/20260314120000_remove_unique_email_family_index_from_invitations.rb new file mode 100644 index 000000000..d714a4143 --- /dev/null +++ b/db/migrate/20260314120000_remove_unique_email_family_index_from_invitations.rb @@ -0,0 +1,9 @@ +class RemoveUniqueEmailFamilyIndexFromInvitations < ActiveRecord::Migration[7.2] + def change + remove_index :invitations, [ :email, :family_id ], name: "index_invitations_on_email_and_family_id" + add_index :invitations, [ :email, :family_id ], + name: "index_invitations_on_email_and_family_id_pending", + unique: true, + where: "accepted_at IS NULL" + end +end diff --git a/db/migrate/20260314131357_create_archived_exports.rb b/db/migrate/20260314131357_create_archived_exports.rb new file mode 100644 index 000000000..1fecb099e --- /dev/null +++ b/db/migrate/20260314131357_create_archived_exports.rb @@ -0,0 +1,15 @@ +class CreateArchivedExports < ActiveRecord::Migration[7.2] + def change + create_table :archived_exports, id: :uuid do |t| + t.string :email, null: false + t.string :family_name + t.string :download_token_digest, null: false + t.datetime :expires_at, null: false + + t.timestamps + end + + add_index :archived_exports, :download_token_digest, unique: true + add_index :archived_exports, :expires_at + end +end diff --git a/db/migrate/20260316120000_create_vector_store_chunks.rb b/db/migrate/20260316120000_create_vector_store_chunks.rb new file mode 100644 index 000000000..216768486 --- /dev/null +++ b/db/migrate/20260316120000_create_vector_store_chunks.rb @@ -0,0 +1,47 @@ +class CreateVectorStoreChunks < ActiveRecord::Migration[7.2] + def up + return unless pgvector_available? + + enable_extension "vector" unless extension_enabled?("vector") + + create_table :vector_store_chunks, id: :uuid do |t| + t.string :store_id, null: false + t.string :file_id, null: false + t.string :filename + t.integer :chunk_index, null: false, default: 0 + t.text :content, null: false + t.column :embedding, "vector(#{ENV.fetch('EMBEDDING_DIMENSIONS', '1024')})", null: false + t.jsonb :metadata, null: false, default: {} + t.timestamps null: false + end + + add_index :vector_store_chunks, :store_id + add_index :vector_store_chunks, :file_id + add_index :vector_store_chunks, [ :store_id, :file_id, :chunk_index ], unique: true, + name: "index_vector_store_chunks_on_store_file_chunk" + end + + def down + drop_table :vector_store_chunks, if_exists: true + disable_extension "vector" if extension_enabled?("vector") + end + + private + + # Only run this migration when pgvector is explicitly configured as the + # vector store provider AND the extension is actually available on the + # PostgreSQL server. Previously we only checked server availability, + # which caused failures in production Docker environments where the + # extension may be present but the DB user lacks superuser privileges + # to enable it. + def pgvector_available? + return false unless ENV["VECTOR_STORE_PROVIDER"].to_s.downcase == "pgvector" + + result = ActiveRecord::Base.connection.execute( + "SELECT 1 FROM pg_available_extensions WHERE name = 'vector' LIMIT 1" + ) + result.any? + rescue + false + end +end diff --git a/db/migrate/20260320080659_add_parent_entry_id_to_entries.rb b/db/migrate/20260320080659_add_parent_entry_id_to_entries.rb new file mode 100644 index 000000000..164b52567 --- /dev/null +++ b/db/migrate/20260320080659_add_parent_entry_id_to_entries.rb @@ -0,0 +1,6 @@ +class AddParentEntryIdToEntries < ActiveRecord::Migration[7.2] + def change + add_reference :entries, :parent_entry, type: :uuid, null: true, + foreign_key: { to_table: :entries, on_delete: :cascade } + end +end diff --git a/db/migrate/20260322120702_add_fee_to_trades.rb b/db/migrate/20260322120702_add_fee_to_trades.rb new file mode 100644 index 000000000..09b9c91a2 --- /dev/null +++ b/db/migrate/20260322120702_add_fee_to_trades.rb @@ -0,0 +1,5 @@ +class AddFeeToTrades < ActiveRecord::Migration[7.2] + def change + add_column :trades, :fee, :decimal, precision: 19, scale: 4, default: 0, null: false + end +end diff --git a/db/migrate/20260324100000_add_account_sharing_support.rb b/db/migrate/20260324100000_add_account_sharing_support.rb new file mode 100644 index 000000000..253606850 --- /dev/null +++ b/db/migrate/20260324100000_add_account_sharing_support.rb @@ -0,0 +1,21 @@ +class AddAccountSharingSupport < ActiveRecord::Migration[7.2] + def change + # Family-level default: whether new accounts are shared with all members by default + add_column :families, :default_account_sharing, :string, default: "shared", null: false + + # Account ownership: who created/owns the account + add_reference :accounts, :owner, type: :uuid, foreign_key: { to_table: :users }, null: true, index: true + + # Sharing join table: per-user access to accounts they don't own + create_table :account_shares, id: :uuid, default: -> { "gen_random_uuid()" } do |t| + t.references :account, type: :uuid, null: false, foreign_key: true + t.references :user, type: :uuid, null: false, foreign_key: true + t.string :permission, null: false, default: "read_only" + t.boolean :include_in_finances, null: false, default: true + t.timestamps + end + + add_index :account_shares, [ :account_id, :user_id ], unique: true + add_index :account_shares, [ :user_id, :include_in_finances ] + end +end diff --git a/db/migrate/20260324100001_backfill_account_owners_and_shares.rb b/db/migrate/20260324100001_backfill_account_owners_and_shares.rb new file mode 100644 index 000000000..8ed6b0416 --- /dev/null +++ b/db/migrate/20260324100001_backfill_account_owners_and_shares.rb @@ -0,0 +1,34 @@ +class BackfillAccountOwnersAndShares < ActiveRecord::Migration[7.2] + def up + # Existing families keep current behavior: all accounts shared + Family.update_all(default_account_sharing: "shared") + + # For each family, assign all accounts to the admin (or first user) + Family.find_each do |family| + admin = family.users.find_by(role: %w[admin super_admin]) || family.users.order(:created_at).first + next unless admin + + family.accounts.where(owner_id: nil).update_all(owner_id: admin.id) + + # Create shares for non-owner members (preserves current full-access behavior) + member_ids = family.users.where.not(id: admin.id).pluck(:id) + account_ids = family.accounts.pluck(:id) + + if member_ids.any? && account_ids.any? + records = member_ids.product(account_ids).map do |user_id, account_id| + { user_id: user_id, account_id: account_id, permission: "full_control", + include_in_finances: true, created_at: Time.current, updated_at: Time.current } + end + + AccountShare.upsert_all(records, unique_by: %i[account_id user_id]) + end + end + + # Owner is enforced at the model level via before_validation callback + # Keeping nullable at DB level for backward compatibility with tests/seeds + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/db/migrate/20260324100002_add_check_constraints_to_sharing_columns.rb b/db/migrate/20260324100002_add_check_constraints_to_sharing_columns.rb new file mode 100644 index 000000000..0a21a4113 --- /dev/null +++ b/db/migrate/20260324100002_add_check_constraints_to_sharing_columns.rb @@ -0,0 +1,6 @@ +class AddCheckConstraintsToSharingColumns < ActiveRecord::Migration[7.2] + def change + add_check_constraint :families, "default_account_sharing IN ('shared', 'private')", name: "chk_families_default_account_sharing" + add_check_constraint :account_shares, "permission IN ('full_control', 'read_write', 'read_only')", name: "chk_account_shares_permission" + end +end diff --git a/db/migrate/20260324100003_change_accounts_owner_fk_to_nullify.rb b/db/migrate/20260324100003_change_accounts_owner_fk_to_nullify.rb new file mode 100644 index 000000000..bd2ce4298 --- /dev/null +++ b/db/migrate/20260324100003_change_accounts_owner_fk_to_nullify.rb @@ -0,0 +1,11 @@ +class ChangeAccountsOwnerFkToNullify < ActiveRecord::Migration[7.2] + def up + remove_foreign_key :accounts, :users, column: :owner_id + add_foreign_key :accounts, :users, column: :owner_id, on_delete: :nullify + end + + def down + remove_foreign_key :accounts, :users, column: :owner_id + add_foreign_key :accounts, :users, column: :owner_id + end +end diff --git a/db/migrate/20260326112218_add_account_id_to_recurring_transactions.rb b/db/migrate/20260326112218_add_account_id_to_recurring_transactions.rb new file mode 100644 index 000000000..d8289f59d --- /dev/null +++ b/db/migrate/20260326112218_add_account_id_to_recurring_transactions.rb @@ -0,0 +1,64 @@ +class AddAccountIdToRecurringTransactions < ActiveRecord::Migration[7.2] + def up + add_reference :recurring_transactions, :account, type: :uuid, null: true, foreign_key: true + + # Backfill account_id from the most recent matching entry + execute <<~SQL + UPDATE recurring_transactions rt + SET account_id = subquery.account_id + FROM ( + SELECT DISTINCT ON (rt2.id) rt2.id AS recurring_transaction_id, e.account_id + FROM recurring_transactions rt2 + JOIN entries e ON e.entryable_type = 'Transaction' + AND e.currency = rt2.currency + AND e.amount = rt2.amount + AND EXTRACT(DAY FROM e.date) BETWEEN GREATEST(rt2.expected_day_of_month - 2, 1) AND LEAST(rt2.expected_day_of_month + 2, 31) + JOIN accounts a ON a.id = e.account_id AND a.family_id = rt2.family_id + LEFT JOIN transactions t ON t.id = e.entryable_id + WHERE rt2.account_id IS NULL + AND ( + (rt2.merchant_id IS NOT NULL AND t.merchant_id = rt2.merchant_id) + OR (rt2.merchant_id IS NULL AND e.name = rt2.name) + ) + ORDER BY rt2.id, e.date DESC + ) subquery + WHERE rt.id = subquery.recurring_transaction_id + SQL + + # Remove old unique indexes + remove_index :recurring_transactions, name: "idx_recurring_txns_merchant", if_exists: true + remove_index :recurring_transactions, name: "idx_recurring_txns_name", if_exists: true + + # Add new unique indexes that include account_id + add_index :recurring_transactions, + [ :family_id, :account_id, :merchant_id, :amount, :currency ], + unique: true, + where: "merchant_id IS NOT NULL", + name: "idx_recurring_txns_acct_merchant" + + add_index :recurring_transactions, + [ :family_id, :account_id, :name, :amount, :currency ], + unique: true, + where: "name IS NOT NULL AND merchant_id IS NULL", + name: "idx_recurring_txns_acct_name" + end + + def down + remove_index :recurring_transactions, name: "idx_recurring_txns_acct_merchant", if_exists: true + remove_index :recurring_transactions, name: "idx_recurring_txns_acct_name", if_exists: true + + add_index :recurring_transactions, + [ :family_id, :merchant_id, :amount, :currency ], + unique: true, + where: "merchant_id IS NOT NULL", + name: "idx_recurring_txns_merchant" + + add_index :recurring_transactions, + [ :family_id, :name, :amount, :currency ], + unique: true, + where: "name IS NOT NULL AND merchant_id IS NULL", + name: "idx_recurring_txns_name" + + remove_reference :recurring_transactions, :account + end +end diff --git a/db/migrate/20260327103000_add_exchange_portfolio_fields_to_coinstats_items.rb b/db/migrate/20260327103000_add_exchange_portfolio_fields_to_coinstats_items.rb new file mode 100644 index 000000000..5b493ae31 --- /dev/null +++ b/db/migrate/20260327103000_add_exchange_portfolio_fields_to_coinstats_items.rb @@ -0,0 +1,11 @@ +class AddExchangePortfolioFieldsToCoinstatsItems < ActiveRecord::Migration[7.2] + def change + add_column :coinstats_items, :exchange_portfolio_id, :string + add_column :coinstats_items, :exchange_connection_id, :string + + add_index :coinstats_items, [ :family_id, :exchange_portfolio_id ], + unique: true, + where: "exchange_portfolio_id IS NOT NULL" + add_index :coinstats_items, :exchange_connection_id + end +end diff --git a/db/migrate/20260327130000_increase_crypto_quantity_precision.rb b/db/migrate/20260327130000_increase_crypto_quantity_precision.rb new file mode 100644 index 000000000..1288f9442 --- /dev/null +++ b/db/migrate/20260327130000_increase_crypto_quantity_precision.rb @@ -0,0 +1,11 @@ +class IncreaseCryptoQuantityPrecision < ActiveRecord::Migration[7.2] + def up + change_column :holdings, :qty, :decimal, precision: 24, scale: 8, null: false + change_column :trades, :qty, :decimal, precision: 24, scale: 8 + end + + def down + change_column :holdings, :qty, :decimal, precision: 19, scale: 4, null: false + change_column :trades, :qty, :decimal, precision: 19, scale: 4 + end +end diff --git a/db/migrate/20260328120000_add_kind_to_securities.rb b/db/migrate/20260328120000_add_kind_to_securities.rb new file mode 100644 index 000000000..3a31ff62a --- /dev/null +++ b/db/migrate/20260328120000_add_kind_to_securities.rb @@ -0,0 +1,7 @@ +class AddKindToSecurities < ActiveRecord::Migration[7.2] + def change + add_column :securities, :kind, :string, null: false, default: "standard" + add_index :securities, :kind + add_check_constraint :securities, "kind IN ('standard', 'cash')", name: "chk_securities_kind" + end +end diff --git a/db/migrate/20260329111830_create_binance_items_and_accounts.rb b/db/migrate/20260329111830_create_binance_items_and_accounts.rb new file mode 100644 index 000000000..949ca46cb --- /dev/null +++ b/db/migrate/20260329111830_create_binance_items_and_accounts.rb @@ -0,0 +1,48 @@ +class CreateBinanceItemsAndAccounts < ActiveRecord::Migration[7.2] + def change + create_table :binance_items, id: :uuid do |t| + t.references :family, null: false, foreign_key: true, type: :uuid + t.string :name + + t.string :institution_name + t.string :institution_domain + t.string :institution_url + t.string :institution_color + + t.string :status, default: "good" + t.boolean :scheduled_for_deletion, default: false + t.boolean :pending_account_setup, default: false + + t.datetime :sync_start_date + t.jsonb :raw_payload + + t.text :api_key + t.text :api_secret + + t.timestamps + end + + add_index :binance_items, :status + + create_table :binance_accounts, id: :uuid do |t| + t.references :binance_item, null: false, foreign_key: true, type: :uuid + + t.string :name + t.string :account_type + t.string :currency + t.decimal :current_balance, precision: 19, scale: 4 + + t.jsonb :institution_metadata + t.jsonb :raw_payload + t.jsonb :raw_transactions_payload + t.jsonb :extra, default: {}, null: false + + t.timestamps + end + + add_index :binance_accounts, :account_type + add_index :binance_accounts, [ :binance_item_id, :account_type ], + unique: true, + name: "index_binance_accounts_on_item_and_type" + end +end diff --git a/db/schema.rb b/db/schema.rb index f63254146..dfbb6be1a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_02_22_100001) do +ActiveRecord::Schema[7.2].define(version: 2026_03_30_050801) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -29,6 +29,20 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_22_100001) do t.index ["provider_type", "provider_id"], name: "index_account_providers_on_provider_type_and_provider_id", unique: true end + create_table "account_shares", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "user_id", null: false + t.string "permission", default: "read_only", null: false + t.boolean "include_in_finances", default: true, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id", "user_id"], name: "index_account_shares_on_account_id_and_user_id", unique: true + t.index ["account_id"], name: "index_account_shares_on_account_id" + t.index ["user_id", "include_in_finances"], name: "index_account_shares_on_user_id_and_include_in_finances" + t.index ["user_id"], name: "index_account_shares_on_user_id" + t.check_constraint "permission::text = ANY (ARRAY['full_control'::character varying::text, 'read_write'::character varying::text, 'read_only'::character varying::text])", name: "chk_account_shares_permission" + end + create_table "accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "subtype" t.uuid "family_id", null: false @@ -49,8 +63,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_22_100001) do t.string "institution_name" t.string "institution_domain" t.text "notes" - t.jsonb "holdings_snapshot_data" - t.datetime "holdings_snapshot_at" + t.uuid "owner_id" t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type" t.index ["accountable_type"], name: "index_accounts_on_accountable_type" t.index ["currency"], name: "index_accounts_on_currency" @@ -60,6 +73,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_22_100001) do t.index ["family_id", "status"], name: "index_accounts_on_family_id_and_status" t.index ["family_id"], name: "index_accounts_on_family_id" t.index ["import_id"], name: "index_accounts_on_import_id" + t.index ["owner_id"], name: "index_accounts_on_owner_id" t.index ["plaid_account_id"], name: "index_accounts_on_plaid_account_id" t.index ["simplefin_account_id"], name: "index_accounts_on_simplefin_account_id" t.index ["status"], name: "index_accounts_on_status" @@ -125,6 +139,17 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_22_100001) do t.index ["user_id"], name: "index_api_keys_on_user_id" end + create_table "archived_exports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "email", null: false + t.string "family_name" + t.string "download_token_digest", null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["download_token_digest"], name: "index_archived_exports_on_download_token_digest", unique: true + t.index ["expires_at"], name: "index_archived_exports_on_expires_at" + end + create_table "balances", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "account_id", null: false t.date "date", null: false @@ -152,6 +177,43 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_22_100001) do t.index ["account_id"], name: "index_balances_on_account_id" end + create_table "binance_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "binance_item_id", null: false + t.string "name" + t.string "account_type" + t.string "currency" + t.decimal "current_balance", precision: 19, scale: 4 + t.jsonb "institution_metadata" + t.jsonb "raw_payload" + t.jsonb "raw_transactions_payload" + t.jsonb "extra", default: {}, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_type"], name: "index_binance_accounts_on_account_type" + t.index ["binance_item_id", "account_type"], name: "index_binance_accounts_on_item_and_type", unique: true + t.index ["binance_item_id"], name: "index_binance_accounts_on_binance_item_id" + end + + create_table "binance_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "family_id", null: false + t.string "name" + t.string "institution_name" + t.string "institution_domain" + t.string "institution_url" + t.string "institution_color" + t.string "status", default: "good" + t.boolean "scheduled_for_deletion", default: false + t.boolean "pending_account_setup", default: false + t.datetime "sync_start_date" + t.jsonb "raw_payload" + t.text "api_key" + t.text "api_secret" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["family_id"], name: "index_binance_items_on_family_id" + t.index ["status"], name: "index_binance_items_on_status" + end + create_table "budget_categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "budget_id", null: false t.uuid "category_id", null: false @@ -184,8 +246,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_22_100001) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.uuid "parent_id" - t.string "classification", default: "expense", null: false t.string "lucide_icon", default: "shapes", null: false + t.string "classification_unused", default: "expense", null: false t.index ["family_id"], name: "index_categories_on_family_id" end @@ -215,6 +277,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_22_100001) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_coinbase_accounts_on_account_id" + t.index ["coinbase_item_id", "account_id"], name: "index_coinbase_accounts_on_item_and_account_id", unique: true, where: "(account_id IS NOT NULL)" t.index ["coinbase_item_id"], name: "index_coinbase_accounts_on_coinbase_item_id" end @@ -276,6 +339,10 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_22_100001) do t.string "api_key", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "exchange_portfolio_id" + t.string "exchange_connection_id" + t.index ["exchange_connection_id"], name: "index_coinstats_items_on_exchange_connection_id" + t.index ["family_id", "exchange_portfolio_id"], name: "index_coinstats_items_on_family_id_and_exchange_portfolio_id", unique: true, where: "(exchange_portfolio_id IS NOT NULL)" t.index ["family_id"], name: "index_coinstats_items_on_family_id" t.index ["status"], name: "index_coinstats_items_on_status" end @@ -387,6 +454,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_22_100001) do t.string "source" t.boolean "user_modified", default: false, null: false t.boolean "import_locked", default: false, null: false + t.uuid "parent_entry_id" t.index "lower((name)::text)", name: "index_entries_on_lower_name" t.index ["account_id", "date"], name: "index_entries_on_account_id_and_date" t.index ["account_id", "source", "external_id"], name: "index_entries_on_account_source_and_external_id", unique: true, where: "((external_id IS NOT NULL) AND (source IS NOT NULL))" @@ -395,6 +463,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_22_100001) do t.index ["entryable_type"], name: "index_entries_on_entryable_type" t.index ["import_id"], name: "index_entries_on_import_id" t.index ["import_locked"], name: "index_entries_on_import_locked_true", where: "(import_locked = true)" + t.index ["parent_entry_id"], name: "index_entries_on_parent_entry_id" t.index ["user_modified"], name: "index_entries_on_user_modified_true", where: "(user_modified = true)" end @@ -504,6 +573,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_22_100001) do t.string "vector_store_id" t.string "moniker", default: "Family", null: false t.string "assistant_type", default: "builtin", null: false + t.string "default_account_sharing", default: "shared", null: false + t.check_constraint "default_account_sharing::text = ANY (ARRAY['shared'::character varying::text, 'private'::character varying::text])", name: "chk_families_default_account_sharing" t.check_constraint "month_start_day >= 1 AND month_start_day <= 28", name: "month_start_day_range" end @@ -545,7 +616,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_22_100001) do t.uuid "account_id", null: false t.uuid "security_id", null: false t.date "date", null: false - t.decimal "qty", precision: 19, scale: 4, null: false + t.decimal "qty", precision: 24, scale: 8, null: false t.decimal "price", precision: 19, scale: 4, null: false t.decimal "amount", precision: 19, scale: 4, null: false t.string "currency", null: false @@ -693,8 +764,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_22_100001) do t.date "sync_start_date" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["indexa_capital_account_id"], name: "index_indexa_capital_accounts_on_indexa_capital_account_id", unique: true t.index ["indexa_capital_authorization_id"], name: "idx_on_indexa_capital_authorization_id_58db208d52" + t.index ["indexa_capital_item_id", "indexa_capital_account_id"], name: "index_indexa_capital_accounts_on_item_and_account_id", unique: true, where: "(indexa_capital_account_id IS NOT NULL)" t.index ["indexa_capital_item_id"], name: "index_indexa_capital_accounts_on_indexa_capital_item_id" end @@ -740,7 +811,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_22_100001) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "token_digest" - t.index ["email", "family_id"], name: "index_invitations_on_email_and_family_id", unique: true + t.index ["email", "family_id"], name: "index_invitations_on_email_and_family_id_pending", unique: true, where: "(accepted_at IS NULL)" t.index ["email"], name: "index_invitations_on_email" t.index ["family_id"], name: "index_invitations_on_family_id" t.index ["inviter_id"], name: "index_invitations_on_inviter_id" @@ -802,6 +873,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_22_100001) do t.boolean "holdings_supported", default: true, null: false t.jsonb "raw_holdings_payload" t.index ["account_id"], name: "index_lunchflow_accounts_on_account_id" + t.index ["lunchflow_item_id", "account_id"], name: "index_lunchflow_accounts_on_item_and_account_id", unique: true, where: "(account_id IS NOT NULL)" t.index ["lunchflow_item_id"], name: "index_lunchflow_accounts_on_lunchflow_item_id" end @@ -859,7 +931,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_22_100001) do t.jsonb "raw_transactions_payload" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["account_id"], name: "index_mercury_accounts_on_account_id", unique: true + t.index ["mercury_item_id", "account_id"], name: "index_mercury_accounts_on_item_and_account_id", unique: true t.index ["mercury_item_id"], name: "index_mercury_accounts_on_mercury_item_id" end @@ -1016,7 +1088,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_22_100001) do t.jsonb "raw_transactions_payload", default: {} t.jsonb "raw_holdings_payload", default: {} t.jsonb "raw_liabilities_payload", default: {} - t.index ["plaid_id"], name: "index_plaid_accounts_on_plaid_id", unique: true + t.index ["plaid_item_id", "plaid_id"], name: "index_plaid_accounts_on_item_and_plaid_id", unique: true t.index ["plaid_item_id"], name: "index_plaid_accounts_on_plaid_item_id" end @@ -1069,8 +1141,10 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_22_100001) do t.decimal "expected_amount_min", precision: 19, scale: 4 t.decimal "expected_amount_max", precision: 19, scale: 4 t.decimal "expected_amount_avg", precision: 19, scale: 4 - t.index ["family_id", "merchant_id", "amount", "currency"], name: "idx_recurring_txns_merchant", unique: true, where: "(merchant_id IS NOT NULL)" - t.index ["family_id", "name", "amount", "currency"], name: "idx_recurring_txns_name", unique: true, where: "((name IS NOT NULL) AND (merchant_id IS NULL))" + t.uuid "account_id" + t.index ["account_id"], name: "index_recurring_transactions_on_account_id" + t.index ["family_id", "account_id", "merchant_id", "amount", "currency"], name: "idx_recurring_txns_acct_merchant", unique: true, where: "(merchant_id IS NOT NULL)" + t.index ["family_id", "account_id", "name", "amount", "currency"], name: "idx_recurring_txns_acct_name", unique: true, where: "((name IS NOT NULL) AND (merchant_id IS NULL))" t.index ["family_id", "status"], name: "index_recurring_transactions_on_family_id_and_status" t.index ["family_id"], name: "index_recurring_transactions_on_family_id" t.index ["merchant_id"], name: "index_recurring_transactions_on_merchant_id" @@ -1172,9 +1246,12 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_22_100001) do t.integer "failed_fetch_count", default: 0, null: false t.datetime "last_health_check_at" t.string "website_url" + t.string "kind", default: "standard", null: false t.index "upper((ticker)::text), COALESCE(upper((exchange_operating_mic)::text), ''::text)", name: "index_securities_on_ticker_and_exchange_operating_mic_unique", unique: true t.index ["country_code"], name: "index_securities_on_country_code" t.index ["exchange_operating_mic"], name: "index_securities_on_exchange_operating_mic" + t.index ["kind"], name: "index_securities_on_kind" + t.check_constraint "kind::text = ANY (ARRAY['standard'::character varying, 'cash'::character varying]::text[])", name: "chk_securities_kind" end create_table "security_prices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -1262,7 +1339,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_22_100001) do create_table "snaptrade_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "snaptrade_item_id", null: false t.string "name" - t.string "account_id" t.string "snaptrade_account_id" t.string "snaptrade_authorization_id" t.string "account_number" @@ -1284,8 +1360,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_22_100001) do t.datetime "updated_at", null: false t.boolean "activities_fetch_pending", default: false t.date "sync_start_date" - t.index ["account_id"], name: "index_snaptrade_accounts_on_account_id", unique: true - t.index ["snaptrade_account_id"], name: "index_snaptrade_accounts_on_snaptrade_account_id", unique: true + t.index ["snaptrade_item_id", "snaptrade_account_id"], name: "index_snaptrade_accounts_on_item_and_snaptrade_account_id", unique: true, where: "(snaptrade_account_id IS NOT NULL)" t.index ["snaptrade_item_id"], name: "index_snaptrade_accounts_on_snaptrade_item_id" end @@ -1416,21 +1491,15 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_22_100001) do create_table "trades", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "security_id", null: false - t.decimal "qty", precision: 19, scale: 4 + t.decimal "qty", precision: 24, scale: 8 t.decimal "price", precision: 19, scale: 10 t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "currency" t.jsonb "locked_attributes", default: {} - t.decimal "realized_gain", precision: 19, scale: 4 - t.decimal "cost_basis_amount", precision: 19, scale: 4 - t.string "cost_basis_currency" - t.integer "holding_period_days" - t.string "realized_gain_confidence" - t.string "realized_gain_currency" t.string "investment_activity_label" + t.decimal "fee", precision: 19, scale: 4, default: "0.0", null: false t.index ["investment_activity_label"], name: "index_trades_on_investment_activity_label" - t.index ["realized_gain"], name: "index_trades_on_realized_gain_not_null", where: "(realized_gain IS NOT NULL)" t.index ["security_id"], name: "index_trades_on_security_id" end @@ -1495,6 +1564,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_22_100001) do t.jsonb "preferences", default: {}, null: false t.string "locale" t.string "ui_layout" + t.uuid "default_account_id" + t.index ["default_account_id"], name: "index_users_on_default_account_id" t.index ["email"], name: "index_users_on_email", unique: true t.index ["family_id"], name: "index_users_on_family_id" t.index ["last_viewed_chat_id"], name: "index_users_on_last_viewed_chat_id" @@ -1523,14 +1594,19 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_22_100001) do end add_foreign_key "account_providers", "accounts", on_delete: :cascade + add_foreign_key "account_shares", "accounts" + add_foreign_key "account_shares", "users" add_foreign_key "accounts", "families" add_foreign_key "accounts", "imports" add_foreign_key "accounts", "plaid_accounts" add_foreign_key "accounts", "simplefin_accounts" + add_foreign_key "accounts", "users", column: "owner_id", on_delete: :nullify add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "api_keys", "users" add_foreign_key "balances", "accounts", on_delete: :cascade + add_foreign_key "binance_accounts", "binance_items" + add_foreign_key "binance_items", "families" add_foreign_key "budget_categories", "budgets" add_foreign_key "budget_categories", "categories" add_foreign_key "budgets", "families" @@ -1543,6 +1619,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_22_100001) do add_foreign_key "enable_banking_accounts", "enable_banking_items" add_foreign_key "enable_banking_items", "families" add_foreign_key "entries", "accounts", on_delete: :cascade + add_foreign_key "entries", "entries", column: "parent_entry_id", on_delete: :cascade add_foreign_key "entries", "imports" add_foreign_key "eval_results", "eval_runs" add_foreign_key "eval_results", "eval_samples" @@ -1579,6 +1656,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_22_100001) do add_foreign_key "pension_entries", "retirement_configs" add_foreign_key "plaid_accounts", "plaid_items" add_foreign_key "plaid_items", "families" + add_foreign_key "recurring_transactions", "accounts" add_foreign_key "recurring_transactions", "families" add_foreign_key "recurring_transactions", "merchants" add_foreign_key "rejected_transfers", "transactions", column: "inflow_transaction_id" @@ -1607,6 +1685,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_22_100001) do add_foreign_key "transactions", "merchants" add_foreign_key "transfers", "transactions", column: "inflow_transaction_id", on_delete: :cascade add_foreign_key "transfers", "transactions", column: "outflow_transaction_id", on_delete: :cascade + add_foreign_key "users", "accounts", column: "default_account_id", on_delete: :nullify add_foreign_key "users", "chats", column: "last_viewed_chat_id" add_foreign_key "users", "families" end diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 4a1515f90..e71c1568c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -545,6 +545,13 @@ components: properties: message: type: string + SuccessMessage: + type: object + required: + - message + properties: + message: + type: string ImportConfiguration: type: object properties: @@ -2718,3 +2725,66 @@ paths: type: string description: Additional notes required: true + "/api/v1/users/reset": + delete: + summary: Reset account + tags: + - Users + description: Resets all financial data (accounts, categories, merchants, tags, + etc.) for the current user's family while keeping the user account intact. + The reset runs asynchronously in the background. Requires admin role. + security: + - apiKeyAuth: [] + responses: + '200': + description: account reset initiated + content: + application/json: + schema: + "$ref": "#/components/schemas/SuccessMessage" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: "forbidden \u2014 requires read_write scope and admin role" + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/users/me": + delete: + summary: Delete account + tags: + - Users + description: Permanently deactivates the current user account and all associated + data. This action cannot be undone. + security: + - apiKeyAuth: [] + responses: + '200': + description: account deleted + content: + application/json: + schema: + "$ref": "#/components/schemas/SuccessMessage" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: insufficient scope + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '422': + description: deactivation failed + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" diff --git a/docs/api/users.md b/docs/api/users.md new file mode 100644 index 000000000..d7f4f1c12 --- /dev/null +++ b/docs/api/users.md @@ -0,0 +1,117 @@ +# Users API Documentation + +The Users API allows external applications to manage user account data within Sure. The OpenAPI description is generated directly from executable request specs, ensuring it always reflects the behaviour of the running Rails application. + +## Generated OpenAPI specification + +- The source of truth for the documentation lives in [`spec/requests/api/v1/users_spec.rb`](../../spec/requests/api/v1/users_spec.rb). These specs authenticate against the Rails stack, exercise every user endpoint, and capture real response shapes. +- Regenerate the OpenAPI document with: + + ```sh + RAILS_ENV=test bundle exec rake rswag:specs:swaggerize + ``` + + The task compiles the request specs and writes the result to [`docs/api/openapi.yaml`](openapi.yaml). + +- Run just the documentation specs with: + + ```sh + bundle exec rspec spec/requests/api/v1/users_spec.rb + ``` + +## Authentication requirements + +All user endpoints require an OAuth2 access token or API key that grants the `read_write` scope. + +## Available endpoints + +| Endpoint | Scope | Description | +| --- | --- | --- | +| `DELETE /api/v1/users/reset` | `read_write` | Reset account data while preserving the user account. | +| `DELETE /api/v1/users/me` | `read_write` | Permanently delete the user account. | + +Refer to the generated [`openapi.yaml`](openapi.yaml) for request/response schemas, reusable components (errors), and security definitions. + +## Reset account + +`DELETE /api/v1/users/reset` + +Resets all financial data (accounts, categories, merchants, tags, transactions, etc.) for the current user's family while keeping the user account intact. The reset runs asynchronously in the background. + +### Request + +No request body required. + +### Response + +```json +{ + "message": "Account reset has been initiated" +} +``` + +### Use cases + +- Clear all financial data to start fresh +- Remove test data after initial setup +- Reset to a clean state for new imports + +## Delete account + +`DELETE /api/v1/users/me` + +Permanently deactivates the current user account and all associated data. This action cannot be undone. + +### Request + +No request body required. + +### Response + +```json +{ + "message": "Account has been deleted" +} +``` + +### Error responses + +In addition to standard error codes (`unauthorized`, `insufficient_scope`), the delete endpoint may return: + +**422 Unprocessable Entity** + +```json +{ + "error": "Failed to delete account", + "details": ["Cannot deactivate admin with other users"] +} +``` + +This occurs when the user cannot be deactivated (for example, an admin user with other active users in the family). + +## Security considerations + +- Both endpoints require the `read_write` scope. Read-only API keys cannot access these endpoints. +- Deactivated users cannot access these endpoints. +- The reset operation preserves the user account, allowing you to continue using Sure with a clean slate. +- The delete operation is permanent and removes the user account entirely. + +## Error responses + +Errors conform to the shared `ErrorResponse` schema in the OpenAPI document: + +```json +{ + "error": "error_code", + "message": "Human readable error message", + "details": ["Optional array of extra context"] +} +``` + +Common error codes include: + +| Code | Description | +| --- | --- | +| `unauthorized` | Missing or invalid API key | +| `insufficient_scope` | API key lacks required `read_write` scope | +| `Failed to delete account` | Account deletion failed (see details field) | \ No newline at end of file diff --git a/docs/hosting/ai.md b/docs/hosting/ai.md index 0e6d56d1f..f13f79046 100644 --- a/docs/hosting/ai.md +++ b/docs/hosting/ai.md @@ -11,6 +11,30 @@ Sure includes an AI assistant that can help users understand their financial dat > 👉 Help us by taking a structured approach to your issue reporting. 🙏 +## Architecture: Two AI Pipelines + +Sure has **two separate AI systems** that operate independently. Understanding this is important because they have different configuration requirements. + +### 1. Chat Assistant (conversational) + +The interactive chat where users ask questions about their finances. Routes through one of two backends: + +- **Builtin** (default): Uses the OpenAI-compatible provider configured via `OPENAI_ACCESS_TOKEN` / `OPENAI_URI_BASE` / `OPENAI_MODEL`. Calls Sure's function tools directly (get_accounts, get_transactions, etc.). +- **External**: Delegates the entire conversation to a remote AI agent. The agent calls back to Sure via MCP to access financial data. Set `ASSISTANT_TYPE=external` as a global override, or configure each family's assistant type in Settings. + +### 2. Auto-Categorization and Merchant Detection (background) + +Background jobs that classify transactions and detect merchants. These **always** use the OpenAI-compatible provider (`OPENAI_ACCESS_TOKEN`), regardless of what the chat assistant uses. They rely on structured function calling with JSON schemas, not conversational chat. + +### What this means in practice + +| Setting | Chat assistant | Auto-categorization | +|---------|---------------|---------------------| +| `ASSISTANT_TYPE=builtin` (default) | Uses OpenAI provider | Uses OpenAI provider | +| `ASSISTANT_TYPE=external` | Uses external agent | Still uses OpenAI provider | + +If you use an external agent for chat, you still need `OPENAI_ACCESS_TOKEN` set for auto-categorization and merchant detection to work. The two systems are fully independent. + ## Quickstart: OpenAI Token The easiest way to get started with AI features in Sure is to use OpenAI: @@ -288,7 +312,436 @@ For self-hosted deployments, you can configure AI settings through the web inter - **OpenAI URI Base** - Custom endpoint (leave blank for OpenAI) - **OpenAI Model** - Model name (required for custom endpoints) -**Note:** Settings in the UI override environment variables. If you change settings in the UI, those values take precedence. +**Note:** Environment variables take precedence over UI settings. When an env var is set, the corresponding UI field is disabled. + +## External AI Assistant + +Instead of using the built-in LLM (which calls OpenAI or a local model directly), you can delegate chat to an **external AI agent**. The agent receives the conversation, can call back to Sure's financial data via MCP, and streams a response. + +This is useful when: +- You have a custom AI agent with domain knowledge, memory, or personality +- You want to use a non-OpenAI-compatible model (the agent translates) +- You want to keep LLM credentials and logic outside Sure entirely + +> [!IMPORTANT] +> **Set `ASSISTANT_TYPE=external` to route all users to the external agent.** Without it, routing falls back to each family's `assistant_type` DB column (configurable per-family in the Settings UI), then defaults to `"builtin"`. If you want a global override that applies to every family regardless of their UI setting, set the env var. If you only want specific families to use the external agent, skip the env var and configure it per-family in Settings. + +> [!NOTE] +> The external assistant handles **chat only**. Auto-categorization and merchant detection still use the OpenAI-compatible provider (`OPENAI_ACCESS_TOKEN`). See [Architecture: Two AI Pipelines](#architecture-two-ai-pipelines) for details. + +### How It Works + +1. User sends a message in the Sure chat UI +2. Sure sends the conversation to your agent's API endpoint (OpenAI chat completions format) +3. Your agent processes it using whatever LLM, tools, or context it needs +4. Your agent can call Sure's `/mcp` endpoint for financial data (accounts, transactions, balance sheet, holdings) +5. Your agent streams the response back to Sure via Server-Sent Events (SSE) + +The agent's API must be **OpenAI chat completions compatible**: accept `POST` with a `messages` array, return SSE with `delta.content` chunks. + +### Configuration + +Configure via the UI or environment variables: + +**Settings UI:** +1. Go to **Settings** -> **Self-Hosting** +2. Set **Assistant type** to "External (remote agent)" +3. Enter the **Endpoint URL** and **API Token** from your agent provider +4. Optionally set an **Agent ID** if the provider hosts multiple agents + +**Environment variables:** +```bash +ASSISTANT_TYPE=external # Global override (or set per-family in UI) +EXTERNAL_ASSISTANT_URL=https://your-agent/v1/chat/completions +EXTERNAL_ASSISTANT_TOKEN=your-api-token +EXTERNAL_ASSISTANT_AGENT_ID=main # Optional, defaults to "main" +EXTERNAL_ASSISTANT_SESSION_KEY=agent:main:main # Optional, for session persistence +EXTERNAL_ASSISTANT_ALLOWED_EMAILS=user@example.com # Optional, comma-separated allowlist +``` + +When environment variables are set, the corresponding UI fields are disabled (env takes precedence). + +### MCP Callback Endpoint + +Sure exposes a [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) endpoint at `/mcp` so your external agent can call back and query financial data. This is how the agent accesses accounts, transactions, balance sheets, and other user data. + +**Protocol:** JSON-RPC 2.0 over HTTP POST + +**Authentication:** Bearer token via `Authorization` header + +**Environment variables:** +```bash +MCP_API_TOKEN=your-secret-token # Bearer token the agent sends to authenticate +MCP_USER_EMAIL=user@example.com # Email of the Sure user the agent acts as +``` + +The agent must send requests to `https://your-sure-instance/mcp` with: +``` +Authorization: Bearer +Content-Type: application/json +``` + +**Supported methods:** + +| Method | Description | +|--------|-------------| +| `initialize` | Handshake, returns server info and capabilities | +| `tools/list` | Lists available tools with names, descriptions, and input schemas | +| `tools/call` | Calls a specific tool by name with arguments | + +**Available tools** (exposed via `tools/list`): + +| Tool | Description | +|------|-------------| +| `get_accounts` | Retrieve account information | +| `get_transactions` | Query transaction history | +| `get_holdings` | Investment holdings data | +| `get_balance_sheet` | Current financial position | +| `get_income_statement` | Income and expenses | +| `import_bank_statement` | Import bank statement data | +| `search_family_files` | Search uploaded documents | + +**Example: list tools** +```bash +curl -X POST https://your-sure-instance/mcp \ + -H "Authorization: Bearer $MCP_API_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +``` + +**Example: call a tool** +```bash +curl -X POST https://your-sure-instance/mcp \ + -H "Authorization: Bearer $MCP_API_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_accounts","arguments":{}}}' +``` + +### OpenClaw Gateway Example + +[OpenClaw](https://github.com/luckyPipewrench/openclaw) is an AI agent gateway that exposes agents as OpenAI-compatible endpoints. If your agent runs behind OpenClaw, configure it like this: + +```bash +ASSISTANT_TYPE=external +EXTERNAL_ASSISTANT_URL=http://your-openclaw-host:18789/v1/chat/completions +EXTERNAL_ASSISTANT_TOKEN=your-gateway-token +EXTERNAL_ASSISTANT_AGENT_ID=your-agent-name +``` + +**OpenClaw setup requirements:** +- The gateway must have `chatCompletions.enabled: true` in its config +- The agent's MCP config must point to Sure's `/mcp` endpoint with the correct `MCP_API_TOKEN` +- The URL format is always `/v1/chat/completions` (OpenAI-compatible) + +**Kubernetes in-cluster example** (agent in a different namespace): +```bash +# URL uses Kubernetes DNS: ..svc.cluster.local: +EXTERNAL_ASSISTANT_URL=http://my-agent.my-namespace.svc.cluster.local:18789/v1/chat/completions +``` + +### Security with Pipelock + +When [Pipelock](https://github.com/luckyPipewrench/pipelock) is enabled (`pipelock.enabled=true` in Helm, or the `pipelock` service in Docker Compose), all traffic between Sure and the external agent is scanned: + +- **Outbound** (Sure -> agent): routed through Pipelock's forward proxy via `HTTPS_PROXY` +- **Inbound** (agent -> Sure /mcp): routed through Pipelock's MCP reverse proxy (port 8889) + +Pipelock scans for prompt injection, DLP violations, and tool poisoning. The external agent does not need Pipelock installed. Sure's Pipelock handles both directions. + +**`NO_PROXY` behavior (Helm/Kubernetes only):** The Helm chart's env template sets `NO_PROXY` to include `.svc.cluster.local` and other internal domains. This means in-cluster agent URLs (like `http://agent.namespace.svc.cluster.local:18789`) bypass the forward proxy and go directly. If your agent is in-cluster, its traffic won't be forward-proxy scanned (but MCP callbacks from the agent are still scanned by the reverse proxy). Docker Compose deployments use a different `NO_PROXY` set; check your compose file for the exact values. + +**`mcpToolPolicy` note:** The Helm chart's `pipelock.mcpToolPolicy.enabled` defaults to `true`. If you haven't defined any policy rules, disable it: + +```yaml +# Helm values +pipelock: + mcpToolPolicy: + enabled: false +``` + +See the [Pipelock documentation](https://github.com/luckyPipewrench/pipelock) for tool policy configuration details. + +### Network Policies (Kubernetes) + +If you use Kubernetes NetworkPolicies (and you should), both Sure and the agent's namespace need rules to allow traffic in both directions. + +> [!WARNING] +> **Port number gotcha:** Kubernetes network policies evaluate **after** kube-proxy DNAT. This means egress rules must use the pod's `targetPort`, not the service port. If your agent's Service maps port 18789 to targetPort 18790, the network policy must allow port **18790**. + +**Sure namespace egress** (Sure calling the agent): +```yaml +# Allow Sure -> agent namespace +- to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: agent-namespace + ports: + - protocol: TCP + port: 18790 # targetPort, not service port! +``` + +**Sure namespace ingress** (agent calling Sure's pipelock MCP reverse proxy): +```yaml +# Allow agent -> Sure pipelock MCP reverse proxy +- from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: agent-namespace + ports: + - protocol: TCP + port: 8889 +``` + +**Agent namespace** needs the reverse: egress to Sure on port 8889, ingress from Sure on its listening port. + +### Access Control + +Use `EXTERNAL_ASSISTANT_ALLOWED_EMAILS` to restrict which users can use the external assistant. When set, only users whose email matches the comma-separated list will see the AI chat. When blank, all users can access it. + +### Docker Compose Example + +```yaml +x-rails-env: &rails_env + ASSISTANT_TYPE: external + EXTERNAL_ASSISTANT_URL: https://your-agent/v1/chat/completions + EXTERNAL_ASSISTANT_TOKEN: your-api-token + MCP_API_TOKEN: your-mcp-token # For agent callback + MCP_USER_EMAIL: user@example.com # User the agent acts as +``` + +Or configure the assistant via the Settings UI after startup (MCP env vars are still required for callback). + +## Assistant Architecture + +Sure's AI assistant system uses a modular architecture that allows different assistant implementations to be plugged in based on configuration. This section explains the architecture for contributors who want to understand or extend the system. + +### Overview + +The assistant system evolved from a monolithic class to a module-based architecture with a registry pattern. This allows Sure to support multiple assistant types (builtin, external) and makes it easy to add new implementations. + +**Key benefits:** +- **Extensible:** Add new assistant types without modifying existing code +- **Configurable:** Choose assistant type per family or globally +- **Isolated:** Each implementation has its own logic and dependencies +- **Testable:** Implementations are independent and can be tested separately + +### Component Hierarchy + +#### `Assistant` Module + +The main entry point for all assistant operations. Located in `app/models/assistant.rb`. + +**Key methods:** + +| Method | Description | +|--------|-------------| +| `.for_chat(chat)` | Returns the appropriate assistant instance for a chat | +| `.config_for(chat)` | Returns configuration for builtin assistants | +| `.available_types` | Lists all registered assistant types | +| `.function_classes` | Returns all available function/tool classes | + +**Example usage:** + +```ruby +# Get an assistant for a chat +assistant = Assistant.for_chat(chat) + +# Respond to a message +assistant.respond_to(message) +``` + +#### `Assistant::Base` + +Abstract base class that all assistant implementations inherit from. Located in `app/models/assistant/base.rb`. + +**Contract:** +- Must implement `respond_to(message)` instance method +- Includes `Assistant::Broadcastable` for real-time updates +- Receives the `chat` object in the initializer + +**Example implementation:** + +```ruby +class Assistant::MyCustom < Assistant::Base + def respond_to(message) + # Your custom logic here + assistant_message = AssistantMessage.new(chat: chat, content: "Response") + assistant_message.save! + end +end +``` + +#### `Assistant::Builtin` + +The default implementation that uses the configured OpenAI-compatible LLM provider. Located in `app/models/assistant/builtin.rb`. + +**Features:** +- Uses `Assistant::Provided` for LLM provider selection +- Uses `Assistant::Configurable` for system prompts and function configuration +- Supports function calling via `Assistant::FunctionToolCaller` +- Streams responses in real-time + +**Key methods:** + +| Method | Description | +|--------|-------------| +| `.for_chat(chat)` | Creates a new builtin assistant with config | +| `#respond_to(message)` | Processes a message using the LLM | + +#### `Assistant::External` + +Implementation for delegating chat to a remote AI agent. Located in `app/models/assistant/external.rb`. + +**Features:** +- Sends conversation to external agent via OpenAI-compatible API +- Agent calls back to Sure's `/mcp` endpoint for financial data +- Supports access control via email allowlist +- Streams responses from the agent + +**Configuration:** + +```ruby +config = Assistant::External.config +# => # +``` + +### Registry Pattern + +The `Assistant` module uses a registry to map type names to implementation classes: + +```ruby +REGISTRY = { + "builtin" => Assistant::Builtin, + "external" => Assistant::External +}.freeze +``` + +**Type selection logic:** + +1. Check `ENV["ASSISTANT_TYPE"]` (global override) +2. Check `chat.user.family.assistant_type` (per-family setting) +3. Default to `"builtin"` + +**Example:** + +```ruby +# Global override +ENV["ASSISTANT_TYPE"] = "external" +Assistant.for_chat(chat) # => Assistant::External instance + +# Per-family setting +family.update(assistant_type: "external") +Assistant.for_chat(chat) # => Assistant::External instance + +# Default +Assistant.for_chat(chat) # => Assistant::Builtin instance +``` + +### Function Registry + +The `Assistant.function_classes` method centralizes all available financial tools: + +```ruby +def self.function_classes + [ + Function::GetTransactions, + Function::GetAccounts, + Function::GetHoldings, + Function::GetBalanceSheet, + Function::GetIncomeStatement, + Function::ImportBankStatement, + Function::SearchFamilyFiles + ] +end +``` + +These functions are: +- Used by builtin assistants for LLM function calling +- Exposed via the MCP endpoint for external agents +- Defined in `app/models/assistant/function/` + +### Adding a New Assistant Type + +To add a custom assistant implementation: + +#### 1. Create the implementation class + +```ruby +# app/models/assistant/my_custom.rb +class Assistant::MyCustom < Assistant::Base + class << self + def for_chat(chat) + new(chat) + end + end + + def respond_to(message) + # Your implementation here + # Must create and save an AssistantMessage + assistant_message = AssistantMessage.new( + chat: chat, + content: "My custom response" + ) + assistant_message.save! + end +end +``` + +#### 2. Register the implementation + +```ruby +# app/models/assistant.rb +REGISTRY = { + "builtin" => Assistant::Builtin, + "external" => Assistant::External, + "my_custom" => Assistant::MyCustom +}.freeze +``` + +#### 3. Add validation + +```ruby +# app/models/family.rb +ASSISTANT_TYPES = %w[builtin external my_custom].freeze +``` + +#### 4. Use the new type + +```bash +# Global override +ASSISTANT_TYPE=my_custom + +# Or set per-family in the database +family.update(assistant_type: "my_custom") +``` + +### Integration Points + +#### Pipelock Integration + +For external assistants, Pipelock can scan traffic: +- **Outbound:** Sure -> agent (via `HTTPS_PROXY`) +- **Inbound:** Agent -> Sure /mcp (via MCP reverse proxy on port 8889) + +See the [External AI Assistant](#external-ai-assistant) and [Pipelock](pipelock.md) documentation for configuration. + +#### OpenClaw/WebSocket Support + +The `Assistant::External` implementation currently uses HTTP streaming. Future implementations could use WebSocket connections via OpenClaw or other gateways. + +**Example future implementation:** + +```ruby +class Assistant::WebSocket < Assistant::Base + def respond_to(message) + # Connect via WebSocket + # Stream bidirectional communication + # Handle tool calls via MCP + end +end +``` + +Register it in the `REGISTRY` and add to `Family::ASSISTANT_TYPES` to activate. ## AI Cache Management @@ -589,6 +1042,42 @@ ollama pull model-name # Install a model 3. Restart Sure after changing environment variables 4. Check logs for specific error messages +### "Failed to generate response" with External Assistant + +**Symptom:** Chat shows "Failed to generate response" when expecting the external assistant + +**Check in order:** + +1. **Is external routing active?** Sure uses external mode when `ASSISTANT_TYPE=external` is set as an env var, OR when the family's `assistant_type` is set to "external" in Settings. Check what the pod sees: + ```bash + kubectl exec deploy/sure-web -c rails -- env | grep ASSISTANT_TYPE + kubectl exec deploy/sure-worker -c sidekiq -- env | grep ASSISTANT_TYPE + ``` + If the env var is unset, check the family setting in the database or Settings UI. + +2. **Can Sure reach the agent?** Test from inside the worker pod (use `sh -c` so the env var expands inside the pod, not locally): + ```bash + kubectl exec deploy/sure-worker -c sidekiq -- \ + sh -c 'curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer $EXTERNAL_ASSISTANT_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"model\":\"test\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}]}" \ + $EXTERNAL_ASSISTANT_URL' + ``` + - **Exit code 7 (connection refused):** Network policy is blocking. Check egress rules, and remember to use the `targetPort`, not the service port. + - **HTTP 401/403:** Token mismatch between Sure's `EXTERNAL_ASSISTANT_TOKEN` and the agent's expected token. + - **HTTP 404:** Wrong URL path. Must be `/v1/chat/completions`. + +3. **Check worker logs** for the actual error: + ```bash + kubectl logs deploy/sure-worker -c sidekiq --tail=50 | grep -i "external\|assistant\|error" + ``` + +4. **If using Pipelock:** Check pipelock sidecar logs. A crashed pipelock can block outbound requests: + ```bash + kubectl logs deploy/sure-worker -c pipelock --tail=20 + ``` + ### High Costs **Symptom:** Unexpected bills from cloud provider @@ -608,7 +1097,7 @@ ollama pull model-name # Install a model ### Custom System Prompts -Sure's AI assistant uses a system prompt that defines its behavior. The prompt is defined in `app/models/assistant/configurable.rb`. +The builtin AI assistant uses a system prompt that defines its behavior. The prompt is defined in `app/models/assistant/configurable.rb`. This does not apply to external assistants, which manage their own prompts. To customize: 1. Fork the repository @@ -628,8 +1117,11 @@ The assistant uses OpenAI's function calling (tool use) to access user data: **Available functions:** - `get_transactions` - Retrieve transaction history - `get_accounts` - Get account information +- `get_holdings` - Investment holdings data - `get_balance_sheet` - Current financial position - `get_income_statement` - Income and expenses +- `import_bank_statement` - Import bank statement data +- `search_family_files` - Search uploaded documents These are defined in `app/models/assistant/function/`. @@ -648,7 +1140,7 @@ Sure's AI assistant can search documents that have been uploaded to a family's v | Backend | Status | Best For | Requirements | |---------|--------|----------|--------------| | **OpenAI** (default) | ready | Cloud deployments, zero setup | `OPENAI_ACCESS_TOKEN` | -| **Pgvector** | scaffolded | Self-hosted, full data privacy | PostgreSQL with `pgvector` extension | +| **Pgvector** | ready | Self-hosted, full data privacy | PostgreSQL with `pgvector` extension + embedding model | | **Qdrant** | scaffolded | Self-hosted, dedicated vector DB | Running Qdrant instance | #### Configuration @@ -658,22 +1150,35 @@ Sure's AI assistant can search documents that have been uploaded to a family's v No extra configuration is needed. If you already have `OPENAI_ACCESS_TOKEN` set for the AI assistant, document search works automatically. OpenAI manages chunking, embedding, and retrieval. ```bash -# Already set for AI chat — document search uses the same token +# Already set for AI chat - document search uses the same token OPENAI_ACCESS_TOKEN=sk-proj-... ``` ##### Pgvector (Self-Hosted) -> [!CAUTION] -> Only `OpenAI` has been implemented! +Use PostgreSQL's pgvector extension for fully local document search. All data stays on your infrastructure. -Use PostgreSQL's pgvector extension for fully local document search: +**Requirements:** +- Use the `pgvector/pgvector:pg16` Docker image instead of `postgres:16` (drop-in replacement) +- An embedding model served via an OpenAI-compatible `/v1/embeddings` endpoint (e.g. Ollama with `nomic-embed-text`) +- Run the migration with `VECTOR_STORE_PROVIDER=pgvector` to create the `vector_store_chunks` table ```bash +# Required VECTOR_STORE_PROVIDER=pgvector + +# Embedding model configuration +EMBEDDING_MODEL=nomic-embed-text # Default: nomic-embed-text +EMBEDDING_DIMENSIONS=1024 # Default: 1024 (must match your model) +EMBEDDING_URI_BASE=http://ollama:11434/v1 # Falls back to OPENAI_URI_BASE if not set +EMBEDDING_ACCESS_TOKEN= # Falls back to OPENAI_ACCESS_TOKEN if not set ``` -> **Note:** The pgvector adapter is currently a skeleton. A future release will add full support including embedding model configuration. +If you are using Ollama (as in `compose.example.ai.yml`), pull the embedding model: + +```bash +docker compose exec ollama ollama pull nomic-embed-text +``` ##### Qdrant (Self-Hosted) @@ -777,4 +1282,4 @@ For issues with AI features: --- -**Last Updated:** October 2025 +**Last Updated:** March 2026 diff --git a/docs/hosting/docker.md b/docs/hosting/docker.md index 8fd6a25cf..09114ed09 100644 --- a/docs/hosting/docker.md +++ b/docs/hosting/docker.md @@ -152,6 +152,62 @@ Your app is now set up. You can visit it at `http://localhost:3000` in your brow If you find bugs or have a feature request, be sure to read through our [contributing guide here](https://github.com/we-promise/sure/wiki/How-to-Contribute-Effectively-to-Sure). +## AI features, external assistant, and Pipelock + +Sure ships with a separate compose file for AI-related features: `compose.example.ai.yml`. It adds: + +- **Pipelock** (always on): AI agent security proxy that scans outbound LLM calls and inbound MCP traffic +- **Ollama + Open WebUI** (optional `--profile ai`): local LLM inference + +### Using the AI compose file + +```bash +# Download both compose files +curl -o compose.yml https://raw.githubusercontent.com/we-promise/sure/main/compose.example.yml +curl -o compose.ai.yml https://raw.githubusercontent.com/we-promise/sure/main/compose.example.ai.yml +curl -o pipelock.example.yaml https://raw.githubusercontent.com/we-promise/sure/main/pipelock.example.yaml + +# Run with Pipelock (no local LLM) +docker compose -f compose.ai.yml up -d + +# Run with Pipelock + Ollama +docker compose -f compose.ai.yml --profile ai up -d +``` + +### Setting up the external AI assistant + +The external assistant delegates chat to a remote AI agent instead of calling LLMs directly. The agent calls back to Sure's `/mcp` endpoint for financial data (accounts, transactions, balance sheet). + +1. Set the MCP endpoint credentials in your `.env`: + ```bash + MCP_API_TOKEN=generate-a-random-token-here + MCP_USER_EMAIL=your@email.com # must match an existing Sure user + ``` + +2. Set the external assistant connection: + ```bash + EXTERNAL_ASSISTANT_URL=https://your-agent/v1/chat/completions + EXTERNAL_ASSISTANT_TOKEN=your-agent-api-token + ``` + +3. Choose how to activate: + - **Per-family (UI):** Go to Settings > Self-Hosting > AI Assistant, select "External" + - **Global (env):** Set `ASSISTANT_TYPE=external` to force all families to use external + +See [docs/hosting/ai.md](ai.md) for full configuration details including agent ID, session keys, and email allowlisting. + +### Pipelock security proxy + +Pipelock sits between Sure and external services, scanning AI traffic for: + +- **Secret exfiltration** (DLP): catches API keys, tokens, or personal data leaking in prompts +- **Prompt injection**: detects attempts to override system instructions +- **Tool poisoning**: validates MCP tool calls against known-safe patterns + +When using `compose.example.ai.yml`, Pipelock is always running. External AI agents should connect to port 8889 (MCP reverse proxy) instead of directly to Sure's `/mcp` on port 3000. + +For full Pipelock configuration, see [docs/hosting/pipelock.md](pipelock.md). + ## How to update your app The mechanism that updates your self-hosted Sure app is the GHCR (Github Container Registry) Docker image that you see in the `compose.yml` file: diff --git a/docs/hosting/mcp.md b/docs/hosting/mcp.md new file mode 100644 index 000000000..671feb700 --- /dev/null +++ b/docs/hosting/mcp.md @@ -0,0 +1,338 @@ +# MCP Server for External AI Assistants + +Sure includes a Model Context Protocol (MCP) server endpoint that allows external AI assistants like Claude Desktop, GPT agents, or custom AI clients to query your financial data. + +## What is MCP? + +[Model Context Protocol](https://modelcontextprotocol.io/) is a JSON-RPC 2.0 protocol that enables AI assistants to access structured data and tools from external applications. Instead of copying and pasting financial data into a chat window, your AI assistant can directly query Sure's data through a secure API. + +This is useful when: +- You want to use an external AI assistant (Claude, GPT, custom agents) to analyze your Sure financial data +- You prefer to keep your LLM provider separate from Sure +- You're building custom AI agents that need access to financial tools + +## Prerequisites + +To enable the MCP endpoint, you need to set two environment variables: + +| Variable | Description | Example | +|----------|-------------|---------| +| `MCP_API_TOKEN` | Bearer token for authentication | `your-secret-token-here` | +| `MCP_USER_EMAIL` | Email of the Sure user whose data the assistant can access | `user@example.com` | + +Both variables are **required**. The endpoint will not activate if either is missing. + +### Generating a secure token + +Generate a random token for `MCP_API_TOKEN`: + +```bash +# macOS/Linux +openssl rand -base64 32 + +# Or use any secure password generator +``` + +### Choosing the user + +The `MCP_USER_EMAIL` must match an existing Sure user's email address. The AI assistant will have access to all financial data for that user's family. + +> [!CAUTION] +> The AI assistant will have **read access to all financial data** for the specified user. Only set this for users you trust with your AI provider. + +## Configuration + +### Docker Compose + +Add the environment variables to your `compose.yml`: + +```yaml +x-rails-env: &rails_env + MCP_API_TOKEN: your-secret-token-here + MCP_USER_EMAIL: user@example.com +``` + +Both `web` and `worker` services inherit this configuration. + +### Kubernetes (Helm) + +Add the variables to your `values.yaml` or set them via Secrets: + +```yaml +env: + MCP_API_TOKEN: your-secret-token-here + MCP_USER_EMAIL: user@example.com +``` + +Or create a Secret and reference it: + +```yaml +envFrom: + - secretRef: + name: sure-mcp-credentials +``` + +## Protocol Details + +The MCP endpoint is available at: + +``` +POST /mcp +``` + +### Authentication + +All requests must include the `MCP_API_TOKEN` as a Bearer token: + +``` +Authorization: Bearer +``` + +### Supported Methods + +Sure implements the following JSON-RPC 2.0 methods: + +| Method | Description | +|--------|-------------| +| `initialize` | Protocol handshake, returns server info and capabilities | +| `tools/list` | Lists available financial tools with schemas | +| `tools/call` | Executes a tool with provided arguments | + +### Available Tools + +The MCP endpoint exposes these financial tools: + +| Tool | Description | +|------|-------------| +| `get_transactions` | Retrieve transaction history with filtering | +| `get_accounts` | Get account information and balances | +| `get_holdings` | Query investment holdings | +| `get_balance_sheet` | Current financial position (assets, liabilities, net worth) | +| `get_income_statement` | Income and expenses over a period | +| `import_bank_statement` | Import bank statement data | +| `search_family_files` | Search uploaded documents in the vault | + +These are the same tools used by Sure's builtin AI assistant. + +## Example Requests + +### Initialize + +Handshake to verify protocol version and capabilities: + +```bash +curl -X POST https://your-sure-instance/mcp \ + -H "Authorization: Bearer your-secret-token" \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize" + }' +``` + +Response: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": "2025-03-26", + "capabilities": { + "tools": {} + }, + "serverInfo": { + "name": "sure", + "version": "1.0" + } + } +} +``` + +### List Tools + +Get available tools with their schemas: + +```bash +curl -X POST https://your-sure-instance/mcp \ + -H "Authorization: Bearer your-secret-token" \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list" + }' +``` + +Response includes tool names, descriptions, and JSON schemas for parameters. + +### Call a Tool + +Execute a tool to get transactions: + +```bash +curl -X POST https://your-sure-instance/mcp \ + -H "Authorization: Bearer your-secret-token" \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "get_transactions", + "arguments": { + "start_date": "2024-01-01", + "end_date": "2024-01-31" + } + } + }' +``` + +Response: + +```json +{ + "jsonrpc": "2.0", + "id": 3, + "result": { + "content": [ + { + "type": "text", + "text": "[{\"id\":\"...\",\"amount\":-45.99,\"date\":\"2024-01-15\",\"name\":\"Coffee Shop\"}]" + } + ] + } +} +``` + +## Security Considerations + +### Transient Session Isolation + +The MCP controller creates a **transient session** for each request. This prevents session state leaks that could expose other users' data if the Sure instance is using impersonation features. + +Each MCP request: +1. Authenticates the token +2. Loads the user specified in `MCP_USER_EMAIL` +3. Creates a temporary session scoped to that user +4. Executes the tool call +5. Discards the session + +This ensures the AI assistant can only access data for the intended user. + +### Pipelock Security Scanning + +For production deployments, we recommend using [Pipelock](https://github.com/luckyPipewrench/pipelock) to scan MCP traffic for security threats. + +Pipelock provides: +- **DLP scanning**: Detects secrets being exfiltrated through tool calls +- **Prompt injection detection**: Identifies attempts to manipulate the AI +- **Tool poisoning detection**: Prevents malicious tool call sequences +- **Policy enforcement**: Block or warn on suspicious patterns + +See the [Pipelock documentation](pipelock.md) and the example configuration in `compose.example.pipelock.yml` for setup instructions. + +### Network Security + +The `/mcp` endpoint is exposed on the same port as the web UI (default 3000). For hardened deployments: + +**Docker Compose:** +- The MCP endpoint is protected by the `MCP_API_TOKEN` but is reachable on port 3000 +- For additional security, use Pipelock's MCP reverse proxy (port 8889) which adds scanning +- See `compose.example.ai.yml` for a Pipelock configuration + +**Kubernetes:** +- Use NetworkPolicies to restrict access to the MCP endpoint +- Route external agents through Pipelock's MCP reverse proxy +- See the [Helm chart documentation](../../charts/sure/README.md) for Pipelock ingress setup + +## Production Deployment + +For a production-ready setup with security scanning: + +1. **Download the example configuration:** + + ```bash + curl -o compose.ai.yml https://raw.githubusercontent.com/we-promise/sure/main/compose.example.ai.yml + curl -o pipelock.example.yaml https://raw.githubusercontent.com/we-promise/sure/main/pipelock.example.yaml + ``` + +2. **Set your MCP credentials in `.env`:** + + ```bash + MCP_API_TOKEN=your-secret-token + MCP_USER_EMAIL=user@example.com + ``` + +3. **Start the stack:** + + ```bash + docker compose -f compose.ai.yml up -d + ``` + +4. **Connect your AI assistant to the Pipelock MCP proxy:** + + ``` + http://your-server:8889 + ``` + +The Pipelock proxy (port 8889) scans all MCP traffic before forwarding to Sure's `/mcp` endpoint. + +## Connecting AI Assistants + +### Claude Desktop + +Configure Claude Desktop to use Sure's MCP server: + +1. Open Claude Desktop settings +2. Add a new MCP server +3. Set the endpoint to `http://your-server:8889` (if using Pipelock) or `http://your-server:3000/mcp` +4. Add the authorization header: `Authorization: Bearer your-secret-token` + +### Custom Agents + +Any AI agent that supports JSON-RPC 2.0 can connect to the MCP endpoint. The agent should: + +1. Send a POST request to `/mcp` +2. Include the `Authorization: Bearer ` header +3. Use the JSON-RPC 2.0 format for requests +4. Handle the protocol methods: `initialize`, `tools/list`, `tools/call` + +## Troubleshooting + +### "MCP endpoint not configured" error + +**Symptom:** Requests return HTTP 503 with "MCP endpoint not configured" + +**Fix:** Ensure both `MCP_API_TOKEN` and `MCP_USER_EMAIL` are set as environment variables and restart Sure. + +### "unauthorized" error + +**Symptom:** Requests return HTTP 401 with "unauthorized" + +**Fix:** Verify the `Authorization` header contains the correct token: `Bearer ` + +### "MCP user not configured" error + +**Symptom:** Requests return HTTP 503 with "MCP user not configured" + +**Fix:** The `MCP_USER_EMAIL` does not match an existing user. Check that: +- The email is correct +- The user exists in the database +- There are no typos or extra spaces + +### Pipelock connection refused + +**Symptom:** AI assistant cannot connect to Pipelock's MCP proxy (port 8889) + +**Fix:** +1. Verify Pipelock is running: `docker compose ps pipelock` +2. Check Pipelock health: `docker compose exec pipelock /pipelock healthcheck --addr 127.0.0.1:8888` +3. Verify the port is exposed in your `compose.yml` + +## See Also + +- [External AI Assistant Configuration](ai.md#external-ai-assistant) - Configure Sure's chat to use an external agent +- [Pipelock Security Proxy](pipelock.md) - Set up security scanning for MCP traffic +- [Model Context Protocol Specification](https://modelcontextprotocol.io/) - Official MCP documentation diff --git a/docs/hosting/oidc.md b/docs/hosting/oidc.md index 9100bb5f9..91318c907 100644 --- a/docs/hosting/oidc.md +++ b/docs/hosting/oidc.md @@ -346,6 +346,7 @@ When enabled: When disabled (default): - Providers are loaded from `config/auth.yml` - Changes require a server restart +- In production, YAML is the default unless `AUTH_PROVIDERS_SOURCE=db` is explicitly set ### 6.2 Admin UI for SSO providers diff --git a/docs/hosting/pipelock.md b/docs/hosting/pipelock.md new file mode 100644 index 000000000..b1f039eeb --- /dev/null +++ b/docs/hosting/pipelock.md @@ -0,0 +1,222 @@ +# Pipelock: AI Agent Security Proxy + +[Pipelock](https://github.com/luckyPipewrench/pipelock) is an optional security proxy that scans AI agent traffic flowing through Sure. It protects against secret exfiltration, prompt injection, and tool poisoning. + +## What Pipelock does + +Pipelock runs as a separate proxy service alongside Sure with two listeners: + +| Listener | Port | Direction | What it scans | +|----------|------|-----------|---------------| +| Forward proxy | 8888 | Outbound (Sure to LLM) | DLP (secrets in prompts), response injection | +| MCP reverse proxy | 8889 | Inbound (agent to Sure /mcp) | Prompt injection, tool poisoning, DLP | + +### Forward proxy (outbound) + +When `HTTPS_PROXY=http://pipelock:8888` is set, outbound HTTPS requests from Faraday-based clients (like `ruby-openai`) are routed through Pipelock. It scans request bodies for leaked secrets and response bodies for prompt injection. + +**Covered:** OpenAI API calls via ruby-openai (uses Faraday). +**Not covered:** SimpleFIN, Coinbase, Plaid, or anything using Net::HTTP/HTTParty directly. These bypass `HTTPS_PROXY`. + +### MCP reverse proxy (inbound) + +External AI assistants that call Sure's `/mcp` endpoint should connect through Pipelock on port 8889 instead of directly to port 3000. Pipelock scans: + +- Tool call arguments (DLP, shell obfuscation detection) +- Tool responses (injection payloads) +- Session binding (detects tool inventory manipulation) +- Tool call chains (multi-step attack patterns like recon then exfil) + +## Docker Compose setup + +The `compose.example.ai.yml` file includes Pipelock. To use it: + +1. Download the compose file and Pipelock config: + ```bash + curl -o compose.ai.yml https://raw.githubusercontent.com/we-promise/sure/main/compose.example.ai.yml + curl -o pipelock.example.yaml https://raw.githubusercontent.com/we-promise/sure/main/pipelock.example.yaml + ``` + +2. Start the stack: + ```bash + docker compose -f compose.ai.yml up -d + ``` + +3. Verify Pipelock is healthy: + ```bash + docker compose -f compose.ai.yml ps pipelock + # Should show "healthy" + ``` + +### Connecting external AI agents + +External agents should use the MCP reverse proxy port: + +```text +http://your-server:8889 +``` + +The agent must include the `MCP_API_TOKEN` as a Bearer token in requests. Set this in your `.env`: + +```bash +MCP_API_TOKEN=generate-a-random-token +MCP_USER_EMAIL=your@email.com +``` + +### Running without Pipelock + +To use `compose.example.ai.yml` without Pipelock, remove the `pipelock` service and its `depends_on` entries from `web` and `worker`, then unset the proxy env vars (`HTTPS_PROXY`, `HTTP_PROXY`). + +Or use the standard `compose.example.yml` which does not include Pipelock. + +## Helm (Kubernetes) setup + +Enable Pipelock in your Helm values: + +```yaml +pipelock: + enabled: true + image: + tag: "2.0.0" + mode: balanced +``` + +This creates a separate Deployment, Service, and ConfigMap. The chart auto-injects `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` into web and worker pods. + +v2.0 adds trusted domain allowlisting, MCP tool redirect profiles, enhanced tool poisoning detection (full JSON schema scanning), and per-read kill switch preemption on long-lived connections. Process sandboxing and attack simulation are also available via `extraConfig` and CLI. + +### Exposing MCP to external agents (Kubernetes) + +In Kubernetes, external agents cannot reach the MCP port by default. Enable the Pipelock Ingress: + +```yaml +pipelock: + enabled: true + ingress: + enabled: true + className: nginx + hosts: + - host: pipelock.example.com + paths: + - path: / + pathType: Prefix + tls: + - hosts: [pipelock.example.com] + secretName: pipelock-tls +``` + +Or port-forward for testing: + +```bash +kubectl port-forward svc/sure-pipelock 8889:8889 -n sure +``` + +### Monitoring + +Enable the ServiceMonitor for Prometheus scraping: + +```yaml +pipelock: + serviceMonitor: + enabled: true + interval: 30s + additionalLabels: + release: prometheus +``` + +Metrics are available at `/metrics` on the forward proxy port (8888). + +### Eviction protection + +For production, enable the PodDisruptionBudget: + +```yaml +pipelock: + pdb: + enabled: true + maxUnavailable: 1 +``` + +See the [Helm chart README](../../charts/sure/README.md#pipelock-ai-agent-security-proxy) for all configuration options. + +## Pipelock configuration file + +The `pipelock.example.yaml` file (Docker Compose) or ConfigMap (Helm) controls scanning behavior. Key sections: + +| Section | What it controls | +|---------|-----------------| +| `mode` | `strict` (block threats), `balanced` (warn + block critical), `audit` (log only) | +| `trusted_domains` | Allow internal services whose public DNS resolves to private IPs | +| `forward_proxy` | Outbound HTTPS scanning (tunnel timeouts, idle timeouts) | +| `dlp` | Data loss prevention (scan env vars, built-in patterns) | +| `response_scanning` | Scan LLM responses for prompt injection | +| `mcp_input_scanning` | Scan inbound MCP requests | +| `mcp_tool_scanning` | Validate tool calls, detect drift | +| `mcp_tool_policy` | Pre-execution rules, shell obfuscation, redirect profiles | +| `mcp_session_binding` | Pin tool inventory, detect manipulation | +| `tool_chain_detection` | Multi-step attack patterns | +| `websocket_proxy` | WebSocket frame scanning (disabled by default) | +| `logging` | Output format (json/text), verbosity | + +For the Helm chart, most sections are configurable via `values.yaml`. For additional sections not covered by structured values (session profiling, data budgets, kill switch, sandbox, reverse proxy, adaptive enforcement), use the `extraConfig` escape hatch: + +```yaml +pipelock: + extraConfig: + session_profiling: + enabled: true + max_sessions: 1000 +``` + +## Modes + +| Mode | Behavior | Use case | +|------|----------|----------| +| `strict` | Block all detected threats | Production with sensitive data | +| `balanced` | Warn on low-severity, block on high-severity | Default; good for most deployments | +| `audit` | Log everything, block nothing | Initial rollout, testing | + +Start with `audit` mode to see what Pipelock detects without blocking anything. Review the logs, then switch to `balanced` or `strict`. + +## Limitations + +- Forward proxy only covers Faraday-based HTTP clients. Net::HTTP, HTTParty, and other libraries ignore `HTTPS_PROXY`. +- Docker Compose has no egress network policies. The `/mcp` endpoint on port 3000 is still reachable directly (auth token required). For enforcement, use Kubernetes NetworkPolicies. +- Pipelock scans text content. Binary payloads (images, file uploads) are passed through by default. + +## Troubleshooting + +### Pipelock container not starting + +Check the config file is mounted correctly: +```bash +docker compose -f compose.ai.yml logs pipelock +``` + +Common issues: +- Missing `pipelock.example.yaml` file +- YAML syntax errors in config +- Port conflicts (8888 or 8889 already in use) + +### LLM calls failing with proxy errors + +If AI chat stops working after enabling Pipelock: +```bash +# Check Pipelock logs for blocked requests +docker compose -f compose.ai.yml logs pipelock --tail=50 +``` + +If requests are being incorrectly blocked, switch to `audit` mode in the config file and restart: +```yaml +mode: audit +``` + +### MCP requests not reaching Sure + +Verify the MCP upstream is configured correctly: +```bash +# Test from inside the Pipelock container +docker compose -f compose.ai.yml exec pipelock /pipelock healthcheck --addr 127.0.0.1:8888 +``` + +Check that `MCP_API_TOKEN` and `MCP_USER_EMAIL` are set in your `.env` file and that the email matches an existing Sure user. diff --git a/lib/feature_flags.rb b/lib/feature_flags.rb index e20472e81..bd9c648eb 100644 --- a/lib/feature_flags.rb +++ b/lib/feature_flags.rb @@ -3,9 +3,14 @@ module FeatureFlags class << self def db_sso_providers? - auth_source = ENV.fetch("AUTH_PROVIDERS_SOURCE") do - Rails.configuration.app_mode.self_hosted? ? "db" : "yaml" - end + auth_source = ENV["AUTH_PROVIDERS_SOURCE"] + return auth_source.to_s.downcase == "db" if auth_source.present? + + # In production, prefer YAML by default so boot-time tasks (e.g. db:prepare) + # do not attempt to query SSO provider tables before migrations run. + return false if Rails.env.production? + + auth_source = Rails.configuration.app_mode.self_hosted? ? "db" : "yaml" auth_source.to_s.downcase == "db" end diff --git a/mobile/README.md b/mobile/README.md index 95f2897fb..b7a843467 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -172,6 +172,8 @@ flutter build apk --release flutter build appbundle --release ``` +Android release metadata comes from `pubspec.yaml` (`version: +`). Keep the numeric build code increasing for every release so Android can install upgrades over older APKs. + ### iOS ```bash diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 2f3aca585..5b1a582f5 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -6,9 +6,11 @@ import 'providers/auth_provider.dart'; import 'providers/accounts_provider.dart'; import 'providers/transactions_provider.dart'; import 'providers/chat_provider.dart'; +import 'providers/theme_provider.dart'; import 'screens/backend_config_screen.dart'; import 'screens/login_screen.dart'; import 'screens/main_navigation_screen.dart'; +import 'screens/sso_onboarding_screen.dart'; import 'services/api_config.dart'; import 'services/connectivity_service.dart'; import 'services/log_service.dart'; @@ -34,6 +36,7 @@ class SureApp extends StatelessWidget { ChangeNotifierProvider(create: (_) => ConnectivityService()), ChangeNotifierProvider(create: (_) => AuthProvider()), ChangeNotifierProvider(create: (_) => ChatProvider()), + ChangeNotifierProvider(create: (_) => ThemeProvider()), ChangeNotifierProxyProvider( create: (_) => AccountsProvider(), update: (_, connectivityService, accountsProvider) { @@ -61,7 +64,8 @@ class SureApp extends StatelessWidget { }, ), ], - child: MaterialApp( + child: Consumer( + builder: (context, themeProvider, _) => MaterialApp( title: 'Sure Finances', debugShowCheckedModeBanner: false, theme: ThemeData( @@ -138,14 +142,14 @@ class SureApp extends StatelessWidget { ), ), ), - themeMode: ThemeMode.system, + themeMode: themeProvider.themeMode, routes: { '/config': (context) => const BackendConfigScreen(), '/login': (context) => const LoginScreen(), '/home': (context) => const MainNavigationScreen(), }, home: const AppWrapper(), - ), + )), ); } } @@ -255,6 +259,10 @@ class _AppWrapperState extends State { return const MainNavigationScreen(); } + if (authProvider.ssoOnboardingPending) { + return const SsoOnboardingScreen(); + } + return LoginScreen( onGoToSettings: _goToBackendConfig, ); diff --git a/mobile/lib/providers/accounts_provider.dart b/mobile/lib/providers/accounts_provider.dart index efa6e65bd..35c85a928 100644 --- a/mobile/lib/providers/accounts_provider.dart +++ b/mobile/lib/providers/accounts_provider.dart @@ -3,12 +3,14 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import '../models/account.dart'; import '../services/accounts_service.dart'; +import '../services/balance_sheet_service.dart'; import '../services/offline_storage_service.dart'; import '../services/connectivity_service.dart'; import '../services/log_service.dart'; class AccountsProvider with ChangeNotifier { final AccountsService _accountsService = AccountsService(); + final BalanceSheetService _balanceSheetService = BalanceSheetService(); final OfflineStorageService _offlineStorage = OfflineStorageService(); final LogService _log = LogService.instance; @@ -19,11 +21,23 @@ class AccountsProvider with ChangeNotifier { Map? _pagination; ConnectivityService? _connectivityService; + // Summary / net worth data + String? _netWorthFormatted; + String? _assetsFormatted; + String? _liabilitiesFormatted; + String? _familyCurrency; + bool _isBalanceSheetStale = false; + List get accounts => _accounts; bool get isLoading => _isLoading; bool get isInitializing => _isInitializing; String? get errorMessage => _errorMessage; Map? get pagination => _pagination; + String? get netWorthFormatted => _netWorthFormatted; + String? get assetsFormatted => _assetsFormatted; + String? get liabilitiesFormatted => _liabilitiesFormatted; + String? get familyCurrency => _familyCurrency; + bool get isBalanceSheetStale => _isBalanceSheetStale; List get assetAccounts { final assets = _accounts.where((a) => a.isAsset).toList(); @@ -126,6 +140,11 @@ class AccountsProvider with ChangeNotifier { _errorMessage = 'You are offline. Please connect to the internet to load accounts.'; } + // Fetch balance sheet independently — works even with cached accounts + if (isOnline) { + await _fetchBalanceSheet(accessToken); + } + _isLoading = false; _isInitializing = false; notifyListeners(); @@ -164,11 +183,46 @@ class AccountsProvider with ChangeNotifier { } } + /// Fetches balance sheet data and updates formatted net worth, assets, + /// and liabilities values for display. On failure, marks the existing + /// values as stale rather than clearing them. + Future _fetchBalanceSheet(String accessToken) async { + try { + final result = await _balanceSheetService.getBalanceSheet(accessToken: accessToken); + if (result['success'] == true) { + _familyCurrency = result['currency'] as String?; + final netWorth = result['net_worth'] as Map?; + final assets = result['assets'] as Map?; + final liabilities = result['liabilities'] as Map?; + _netWorthFormatted = netWorth?['formatted'] as String?; + _assetsFormatted = assets?['formatted'] as String?; + _liabilitiesFormatted = liabilities?['formatted'] as String?; + _isBalanceSheetStale = false; + } else { + // Keep existing values but mark as stale + if (_netWorthFormatted != null) { + _isBalanceSheetStale = true; + } + } + } catch (e) { + _log.error('AccountsProvider', 'Error fetching balance sheet: $e'); + // Keep existing values but mark as stale + if (_netWorthFormatted != null) { + _isBalanceSheetStale = true; + } + } + } + void clearAccounts() { _accounts = []; _pagination = null; _errorMessage = null; _isInitializing = true; + _netWorthFormatted = null; + _assetsFormatted = null; + _liabilitiesFormatted = null; + _familyCurrency = null; + _isBalanceSheetStale = false; notifyListeners(); } diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart index 3b6a6510e..884ef3d53 100644 --- a/mobile/lib/providers/auth_provider.dart +++ b/mobile/lib/providers/auth_provider.dart @@ -22,6 +22,15 @@ class AuthProvider with ChangeNotifier { bool _mfaRequired = false; bool _showMfaInput = false; // Track if we should show MFA input field + // SSO onboarding state + bool _ssoOnboardingPending = false; + String? _ssoLinkingCode; + String? _ssoEmail; + String? _ssoFirstName; + String? _ssoLastName; + bool _ssoAllowAccountCreation = false; + bool _ssoHasPendingInvitation = false; + User? get user => _user; bool get isIntroLayout => _user?.isIntroLayout ?? false; bool get aiEnabled => _user?.aiEnabled ?? false; @@ -36,6 +45,15 @@ class AuthProvider with ChangeNotifier { bool get mfaRequired => _mfaRequired; bool get showMfaInput => _showMfaInput; // Expose MFA input state + // SSO onboarding getters + bool get ssoOnboardingPending => _ssoOnboardingPending; + String? get ssoLinkingCode => _ssoLinkingCode; + String? get ssoEmail => _ssoEmail; + String? get ssoFirstName => _ssoFirstName; + String? get ssoLastName => _ssoLastName; + bool get ssoAllowAccountCreation => _ssoAllowAccountCreation; + bool get ssoHasPendingInvitation => _ssoHasPendingInvitation; + AuthProvider() { _loadStoredAuth(); } @@ -266,9 +284,22 @@ class AuthProvider with ChangeNotifier { if (result['success'] == true) { _tokens = result['tokens'] as AuthTokens?; _user = result['user'] as User?; + _ssoOnboardingPending = false; _isLoading = false; notifyListeners(); return true; + } else if (result['account_not_linked'] == true) { + // SSO onboarding needed - store linking data + _ssoOnboardingPending = true; + _ssoLinkingCode = result['linking_code'] as String?; + _ssoEmail = result['email'] as String?; + _ssoFirstName = result['first_name'] as String?; + _ssoLastName = result['last_name'] as String?; + _ssoAllowAccountCreation = result['allow_account_creation'] == true; + _ssoHasPendingInvitation = result['has_pending_invitation'] == true; + _isLoading = false; + notifyListeners(); + return false; } else { _errorMessage = result['error'] as String?; _isLoading = false; @@ -284,6 +315,107 @@ class AuthProvider with ChangeNotifier { } } + Future ssoLinkAccount({ + required String email, + required String password, + }) async { + if (_ssoLinkingCode == null) { + _errorMessage = 'No pending SSO session. Please try signing in again.'; + notifyListeners(); + return false; + } + + _errorMessage = null; + _isLoading = true; + notifyListeners(); + + try { + final result = await _authService.ssoLink( + linkingCode: _ssoLinkingCode!, + email: email, + password: password, + ); + + if (result['success'] == true) { + _tokens = result['tokens'] as AuthTokens?; + _user = result['user'] as User?; + _clearSsoOnboardingState(); + _isLoading = false; + notifyListeners(); + return true; + } else { + _errorMessage = result['error'] as String?; + _isLoading = false; + notifyListeners(); + return false; + } + } catch (e, stackTrace) { + LogService.instance.error('AuthProvider', 'SSO link error: $e\n$stackTrace'); + _errorMessage = 'Failed to link account. Please try again.'; + _isLoading = false; + notifyListeners(); + return false; + } + } + + Future ssoCreateAccount({ + String? firstName, + String? lastName, + }) async { + if (_ssoLinkingCode == null) { + _errorMessage = 'No pending SSO session. Please try signing in again.'; + notifyListeners(); + return false; + } + + _errorMessage = null; + _isLoading = true; + notifyListeners(); + + try { + final result = await _authService.ssoCreateAccount( + linkingCode: _ssoLinkingCode!, + firstName: firstName, + lastName: lastName, + ); + + if (result['success'] == true) { + _tokens = result['tokens'] as AuthTokens?; + _user = result['user'] as User?; + _clearSsoOnboardingState(); + _isLoading = false; + notifyListeners(); + return true; + } else { + _errorMessage = result['error'] as String?; + _isLoading = false; + notifyListeners(); + return false; + } + } catch (e, stackTrace) { + LogService.instance.error('AuthProvider', 'SSO create account error: $e\n$stackTrace'); + _errorMessage = 'Failed to create account. Please try again.'; + _isLoading = false; + notifyListeners(); + return false; + } + } + + void cancelSsoOnboarding() { + _clearSsoOnboardingState(); + notifyListeners(); + } + + void _clearSsoOnboardingState() { + _ssoOnboardingPending = false; + _ssoLinkingCode = null; + _ssoEmail = null; + _ssoFirstName = null; + _ssoLastName = null; + _ssoAllowAccountCreation = false; + _ssoHasPendingInvitation = false; + } + Future logout() async { await _authService.logout(); _tokens = null; diff --git a/mobile/lib/providers/chat_provider.dart b/mobile/lib/providers/chat_provider.dart index 5016c934f..158e2a147 100644 --- a/mobile/lib/providers/chat_provider.dart +++ b/mobile/lib/providers/chat_provider.dart @@ -11,6 +11,7 @@ class ChatProvider with ChangeNotifier { Chat? _currentChat; bool _isLoading = false; bool _isSendingMessage = false; + bool _isWaitingForResponse = false; String? _errorMessage; Timer? _pollingTimer; @@ -22,6 +23,7 @@ class ChatProvider with ChangeNotifier { Chat? get currentChat => _currentChat; bool get isLoading => _isLoading; bool get isSendingMessage => _isSendingMessage; + bool get isWaitingForResponse => _isWaitingForResponse; String? get errorMessage => _errorMessage; /// Fetch list of chats @@ -103,18 +105,31 @@ class ChatProvider with ChangeNotifier { if (result['success'] == true) { final chat = result['chat'] as Chat; - _currentChat = chat; - _chats.insert(0, chat); _errorMessage = null; - // Start polling for AI response if initial message was sent if (initialMessage != null) { + // Inject the user message locally so the UI renders it immediately + // without waiting for the first poll. + final now = DateTime.now(); + final userMessage = Message( + id: 'pending_${now.millisecondsSinceEpoch}', + type: 'text', + role: 'user', + content: initialMessage, + createdAt: now, + updatedAt: now, + ); + _currentChat = chat.copyWith(messages: [userMessage]); + _chats.insert(0, _currentChat!); _startPolling(accessToken, chat.id); + } else { + _currentChat = chat; + _chats.insert(0, chat); } _isLoading = false; notifyListeners(); - return chat; + return _currentChat!; } else { _errorMessage = result['error'] ?? 'Failed to create chat'; _isLoading = false; @@ -244,8 +259,10 @@ class ChatProvider with ChangeNotifier { /// Start polling for new messages (AI responses) void _startPolling(String accessToken, String chatId) { - _stopPolling(); + _pollingTimer?.cancel(); _lastAssistantContentLength = null; + _isWaitingForResponse = true; + notifyListeners(); _pollingTimer = Timer.periodic(const Duration(seconds: 2), (timer) async { await _pollForUpdates(accessToken, chatId); @@ -256,6 +273,7 @@ class ChatProvider with ChangeNotifier { void _stopPolling() { _pollingTimer?.cancel(); _pollingTimer = null; + _isWaitingForResponse = false; } /// Poll for updates @@ -302,6 +320,13 @@ class ChatProvider with ChangeNotifier { if (shouldUpdate) { _currentChat = updatedChat; + // Hide thinking indicator as soon as the first assistant content arrives. + if (_isWaitingForResponse) { + final lastMsg = updatedChat.messages.lastOrNull; + if (lastMsg != null && lastMsg.isAssistant && lastMsg.content.isNotEmpty) { + _isWaitingForResponse = false; + } + } notifyListeners(); } @@ -311,9 +336,10 @@ class ChatProvider with ChangeNotifier { if (newLen > (_lastAssistantContentLength ?? 0)) { _lastAssistantContentLength = newLen; } else { - // Content stable: no growth since last poll + // Content stable: no growth since last poll — done. _stopPolling(); _lastAssistantContentLength = null; + notifyListeners(); } } } diff --git a/mobile/lib/providers/theme_provider.dart b/mobile/lib/providers/theme_provider.dart new file mode 100644 index 000000000..51920a45a --- /dev/null +++ b/mobile/lib/providers/theme_provider.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import '../services/preferences_service.dart'; + +class ThemeProvider extends ChangeNotifier { + ThemeMode _themeMode = ThemeMode.system; + + ThemeMode get themeMode => _themeMode; + + ThemeProvider() { + _loadThemeMode(); + } + + Future _loadThemeMode() async { + try { + final mode = await PreferencesService.instance.getThemeMode(); + _themeMode = _fromString(mode); + } catch (_) { + _themeMode = ThemeMode.system; + } + notifyListeners(); + } + + Future setThemeMode(ThemeMode mode) async { + _themeMode = mode; + notifyListeners(); + await PreferencesService.instance.setThemeMode(_toString(mode)); + } + + static ThemeMode _fromString(String mode) { + switch (mode) { + case 'light': + return ThemeMode.light; + case 'dark': + return ThemeMode.dark; + default: + return ThemeMode.system; + } + } + + static String _toString(ThemeMode mode) { + switch (mode) { + case ThemeMode.light: + return 'light'; + case ThemeMode.dark: + return 'dark'; + case ThemeMode.system: + return 'system'; + } + } +} diff --git a/mobile/lib/screens/chat_conversation_screen.dart b/mobile/lib/screens/chat_conversation_screen.dart index 66b6d20c4..e5e85d264 100644 --- a/mobile/lib/screens/chat_conversation_screen.dart +++ b/mobile/lib/screens/chat_conversation_screen.dart @@ -5,13 +5,15 @@ import '../models/chat.dart'; import '../providers/auth_provider.dart'; import '../providers/chat_provider.dart'; import '../models/message.dart'; +import '../widgets/typing_indicator.dart'; class _SendMessageIntent extends Intent { const _SendMessageIntent(); } class ChatConversationScreen extends StatefulWidget { - final String chatId; + /// Null means this is a brand-new chat — it will be created on first send. + final String? chatId; const ChatConversationScreen({ super.key, @@ -26,23 +28,78 @@ class _ChatConversationScreenState extends State { final TextEditingController _messageController = TextEditingController(); final ScrollController _scrollController = ScrollController(); + /// Tracks the real chat ID once the chat has been created. + String? _chatId; + + ChatProvider? _chatProvider; + bool _listenerAdded = false; + @override void initState() { super.initState(); - _loadChat(); + _chatId = widget.chatId; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _chatProvider = Provider.of(context, listen: false); + _chatProvider!.addListener(_onChatChanged); + _listenerAdded = true; + if (_chatId == null) { + _chatProvider!.clearCurrentChat(); + } + }); + if (_chatId != null) { + _loadChat(); + } } @override void dispose() { + if (_listenerAdded && _chatProvider != null) { + _chatProvider!.removeListener(_onChatChanged); + _chatProvider = null; + _listenerAdded = false; + } _messageController.dispose(); _scrollController.dispose(); super.dispose(); } - Future _loadChat() async { + void _onChatChanged() { + if (!mounted) return; + final chatProvider = Provider.of(context, listen: false); + if (chatProvider.isWaitingForResponse || chatProvider.isSendingMessage) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _scrollToBottom(); + }); + } + } + + void _scrollToBottom() { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + } + + Future _loadChat({bool forceRefresh = false}) async { + if (_chatId == null) return; + final authProvider = Provider.of(context, listen: false); final chatProvider = Provider.of(context, listen: false); + // Skip fetch if the provider already has this chat loaded (e.g. just created). + if (!forceRefresh && chatProvider.currentChat?.id == _chatId) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _scrollController.hasClients) { + _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + } + }); + return; + } + final accessToken = await authProvider.getValidAccessToken(); if (accessToken == null) { await authProvider.logout(); @@ -51,12 +108,11 @@ class _ChatConversationScreenState extends State { await chatProvider.fetchChat( accessToken: accessToken, - chatId: widget.chatId, + chatId: _chatId!, ); - // Scroll to bottom after loading WidgetsBinding.instance.addPostFrameCallback((_) { - if (_scrollController.hasClients) { + if (mounted && _scrollController.hasClients) { _scrollController.jumpTo(_scrollController.position.maxScrollExtent); } }); @@ -75,25 +131,47 @@ class _ChatConversationScreenState extends State { return; } - final shouldUpdateTitle = chatProvider.currentChat?.hasDefaultTitle == true; - _messageController.clear(); - final delivered = await chatProvider.sendMessage( - accessToken: accessToken, - chatId: widget.chatId, - content: content, - ); - - if (delivered && shouldUpdateTitle) { - await chatProvider.updateChatTitle( + if (_chatId == null) { + // First message in a new chat — create the chat with it. + final chat = await chatProvider.createChat( accessToken: accessToken, - chatId: widget.chatId, title: Chat.generateTitle(content), + initialMessage: content, ); + if (!mounted) return; + if (chat == null) { + // Restore the message so the user doesn't lose it. + _messageController.text = content; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(chatProvider.errorMessage ?? 'Failed to start conversation. Please try again.'), + backgroundColor: Colors.red, + ), + ); + return; + } + setState(() => _chatId = chat.id); + } else { + final shouldUpdateTitle = + chatProvider.currentChat?.hasDefaultTitle == true; + + final delivered = await chatProvider.sendMessage( + accessToken: accessToken, + chatId: _chatId!, + content: content, + ); + + if (delivered && shouldUpdateTitle) { + await chatProvider.updateChatTitle( + accessToken: accessToken, + chatId: _chatId!, + title: Chat.generateTitle(content), + ); + } } - // Scroll to bottom after sending WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { _scrollController.animateTo( @@ -137,13 +215,17 @@ class _ChatConversationScreenState extends State { }, ); - if (newTitle != null && newTitle.isNotEmpty && newTitle != currentTitle && mounted) { + if (newTitle != null && + newTitle.isNotEmpty && + newTitle != currentTitle && + mounted) { + if (_chatId == null) return; final authProvider = Provider.of(context, listen: false); final accessToken = await authProvider.getValidAccessToken(); if (accessToken != null) { await chatProvider.updateChatTitle( accessToken: accessToken, - chatId: widget.chatId, + chatId: _chatId!, title: newTitle, ); } @@ -164,57 +246,56 @@ class _ChatConversationScreenState extends State { appBar: AppBar( title: Consumer( builder: (context, chatProvider, _) { + final title = chatProvider.currentChat?.title ?? 'New Conversation'; return GestureDetector( - onTap: _editTitle, + onTap: _chatId != null ? _editTitle : null, child: Row( mainAxisSize: MainAxisSize.min, children: [ Flexible( child: Text( - chatProvider.currentChat?.title ?? 'Chat', + title, overflow: TextOverflow.ellipsis, ), ), - const SizedBox(width: 4), - const Icon(Icons.edit, size: 18), + if (_chatId != null) ...[ + const SizedBox(width: 4), + const Icon(Icons.edit, size: 18), + ], ], ), ); }, ), actions: [ - IconButton( - icon: const Icon(Icons.refresh), - onPressed: _loadChat, - tooltip: 'Refresh', - ), + if (widget.chatId != null) + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () => _loadChat(forceRefresh: true), + tooltip: 'Refresh', + ), ], ), body: Consumer( builder: (context, chatProvider, _) { if (chatProvider.isLoading && chatProvider.currentChat == null) { - return const Center( - child: CircularProgressIndicator(), - ); + return const Center(child: CircularProgressIndicator()); } - if (chatProvider.errorMessage != null && chatProvider.currentChat == null) { + if (chatProvider.errorMessage != null && + chatProvider.currentChat == null && + _chatId != null) { return Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - Icons.error_outline, - size: 64, - color: colorScheme.error, - ), + Icon(Icons.error_outline, + size: 64, color: colorScheme.error), const SizedBox(height: 16), - Text( - 'Failed to load chat', - style: Theme.of(context).textTheme.titleLarge, - ), + Text('Failed to load chat', + style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 8), Text( chatProvider.errorMessage!, @@ -237,68 +318,23 @@ class _ChatConversationScreenState extends State { return Column( children: [ - // Messages list Expanded( - child: messages.isEmpty - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.chat_bubble_outline, - size: 64, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(height: 16), - Text( - 'Start a conversation', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 8), - Text( - 'Send a message to begin chatting with the AI assistant.', - style: TextStyle(color: colorScheme.onSurfaceVariant), - textAlign: TextAlign.center, - ), - ], - ), - ) - : ListView.builder( - controller: _scrollController, - padding: const EdgeInsets.all(16), - itemCount: messages.length, - itemBuilder: (context, index) { - final message = messages[index]; - return _MessageBubble( - message: message, - formatTime: _formatTime, - ); - }, - ), - ), - - // Loading indicator when sending - if (chatProvider.isSendingMessage) - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ), - const SizedBox(width: 12), - Text( - 'AI is thinking...', - style: TextStyle( - color: colorScheme.onSurfaceVariant, - fontStyle: FontStyle.italic, - ), - ), - ], - ), + child: ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(16), + itemCount: messages.length + + (chatProvider.isWaitingForResponse ? 1 : 0), + itemBuilder: (context, index) { + if (index == messages.length) { + return const _TypingIndicatorBubble(); + } + return _MessageBubble( + message: messages[index], + formatTime: _formatTime, + ); + }, ), + ), // Message input Container( @@ -315,7 +351,8 @@ class _ChatConversationScreenState extends State { ), child: Shortcuts( shortcuts: const { - SingleActivator(LogicalKeyboardKey.enter): _SendMessageIntent(), + SingleActivator(LogicalKeyboardKey.enter): + _SendMessageIntent(), }, child: Actions( actions: >{ @@ -343,12 +380,15 @@ class _ChatConversationScreenState extends State { ), maxLines: null, textCapitalization: TextCapitalization.sentences, + autofocus: _chatId == null, ), ), const SizedBox(width: 8), IconButton( icon: const Icon(Icons.send), - onPressed: chatProvider.isSendingMessage ? null : _sendMessage, + onPressed: chatProvider.isSendingMessage + ? null + : _sendMessage, color: colorScheme.primary, iconSize: 28, ), @@ -382,7 +422,8 @@ class _MessageBubble extends StatelessWidget { return Padding( padding: const EdgeInsets.only(bottom: 16), child: Row( - mainAxisAlignment: isUser ? MainAxisAlignment.end : MainAxisAlignment.start, + mainAxisAlignment: + isUser ? MainAxisAlignment.end : MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (!isUser) @@ -398,12 +439,17 @@ class _MessageBubble extends StatelessWidget { const SizedBox(width: 8), Flexible( child: Column( - crossAxisAlignment: isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, + crossAxisAlignment: + isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + SelectionArea( + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( - color: isUser ? colorScheme.primary : colorScheme.surfaceContainerHighest, + color: isUser + ? colorScheme.primary + : colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(16), ), child: Column( @@ -412,10 +458,13 @@ class _MessageBubble extends StatelessWidget { Text( message.content, style: TextStyle( - color: isUser ? colorScheme.onPrimary : colorScheme.onSurfaceVariant, + color: isUser + ? colorScheme.onPrimary + : colorScheme.onSurfaceVariant, ), ), - if (message.toolCalls != null && message.toolCalls!.isNotEmpty) + if (message.toolCalls != null && + message.toolCalls!.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 8), child: Wrap( @@ -436,6 +485,7 @@ class _MessageBubble extends StatelessWidget { ], ), ), + ), const SizedBox(height: 4), Text( formatTime(message.createdAt), @@ -463,3 +513,40 @@ class _MessageBubble extends StatelessWidget { ); } } + +class _TypingIndicatorBubble extends StatelessWidget { + const _TypingIndicatorBubble(); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: 16, + backgroundColor: colorScheme.primaryContainer, + child: Icon( + Icons.smart_toy, + size: 18, + color: colorScheme.onPrimaryContainer, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + ), + child: const TypingIndicator(), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/screens/chat_list_screen.dart b/mobile/lib/screens/chat_list_screen.dart index d49bbd3fd..3c1606ecd 100644 --- a/mobile/lib/screens/chat_list_screen.dart +++ b/mobile/lib/screens/chat_list_screen.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../models/chat.dart'; import '../providers/auth_provider.dart'; import '../providers/chat_provider.dart'; import 'chat_conversation_screen.dart'; @@ -36,56 +35,17 @@ class _ChatListScreenState extends State { await _loadChats(); } - Future _createNewChat() async { - final authProvider = Provider.of(context, listen: false); - final chatProvider = Provider.of(context, listen: false); + Future _openNewChat() async { + if (!mounted) return; - final accessToken = await authProvider.getValidAccessToken(); - if (accessToken == null) { - await authProvider.logout(); - return; - } - - // Show loading dialog - if (mounted) { - showDialog( - context: context, - barrierDismissible: false, - builder: (context) => const Center( - child: CircularProgressIndicator(), - ), - ); - } - - final chat = await chatProvider.createChat( - accessToken: accessToken, - title: Chat.defaultTitle, + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const ChatConversationScreen(chatId: null), + ), ); - // Close loading dialog - if (mounted) { - Navigator.pop(context); - } - - if (chat != null && mounted) { - // Navigate to chat conversation - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ChatConversationScreen(chatId: chat.id), - ), - ); - - // Refresh list after returning - _loadChats(); - } else if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(chatProvider.errorMessage ?? 'Failed to create chat'), - backgroundColor: Colors.red, - ), - ); - } + if (mounted) _loadChats(); } String _formatDateTime(DateTime dateTime) { @@ -297,7 +257,7 @@ class _ChatListScreenState extends State { }, ), floatingActionButton: FloatingActionButton( - onPressed: _createNewChat, + onPressed: _openNewChat, tooltip: 'New Chat', child: const Icon(Icons.add), ), diff --git a/mobile/lib/screens/dashboard_screen.dart b/mobile/lib/screens/dashboard_screen.dart index 9b872b7ab..b42c75ec8 100644 --- a/mobile/lib/screens/dashboard_screen.dart +++ b/mobile/lib/screens/dashboard_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/account.dart'; @@ -12,7 +13,6 @@ import '../widgets/net_worth_card.dart'; import '../widgets/currency_filter.dart'; import 'transaction_form_screen.dart'; import 'transactions_list_screen.dart'; -import 'log_viewer_screen.dart'; class DashboardScreen extends StatefulWidget { const DashboardScreen({super.key}); @@ -25,6 +25,7 @@ class DashboardScreenState extends State { final LogService _log = LogService.instance; bool _showSyncSuccess = false; int _previousPendingCount = 0; + Timer? _syncSuccessTimer; TransactionsProvider? _transactionsProvider; // Filter state @@ -52,6 +53,7 @@ class DashboardScreenState extends State { @override void dispose() { + _syncSuccessTimer?.cancel(); _transactionsProvider?.removeListener(_onTransactionsChanged); super.dispose(); } @@ -61,28 +63,33 @@ class DashboardScreenState extends State { if (transactionsProvider == null || !mounted) { return; } - + final currentPendingCount = transactionsProvider.pendingCount; - // If pending count decreased, it means transactions were synced + // Show sync success when pending count decreased (local transactions uploaded) if (_previousPendingCount > 0 && currentPendingCount < _previousPendingCount) { - setState(() { - _showSyncSuccess = true; - }); - - // Hide the success indicator after 3 seconds - Future.delayed(const Duration(seconds: 3), () { - if (mounted) { - setState(() { - _showSyncSuccess = false; - }); - } - }); + _showSyncSuccessIndicator(); } _previousPendingCount = currentPendingCount; } + void _showSyncSuccessIndicator() { + _syncSuccessTimer?.cancel(); + + setState(() { + _showSyncSuccess = true; + }); + + _syncSuccessTimer = Timer(const Duration(seconds: 3), () { + if (mounted) { + setState(() { + _showSyncSuccess = false; + }); + } + }); + } + Future _loadAccounts() async { final authProvider = Provider.of(context, listen: false); final accountsProvider = Provider.of(context, listen: false); @@ -161,19 +168,23 @@ class DashboardScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).clearSnackBars(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Row( - children: [ - Icon(Icons.check_circle, color: Colors.white), - SizedBox(width: 12), - Text('Sync completed successfully'), - ], + if (transactionsProvider.error != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.error, color: Colors.white), + const SizedBox(width: 12), + const Expanded(child: Text('Sync failed. Please try again.')), + ], + ), + backgroundColor: Colors.red, + duration: const Duration(seconds: 3), ), - backgroundColor: Colors.green, - duration: Duration(seconds: 2), - ), - ); + ); + } else { + _showSyncSuccessIndicator(); + } } } catch (e) { _log.error('DashboardScreen', 'Error in _performManualSync: $e'); @@ -339,83 +350,34 @@ class DashboardScreenState extends State { } } - Future _handleLogout() async { - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Sign Out'), - content: const Text('Are you sure you want to sign out?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - child: const Text('Sign Out'), - ), - ], - ), - ); - - if (confirmed == true && mounted) { - final authProvider = Provider.of(context, listen: false); - final accountsProvider = Provider.of(context, listen: false); - - accountsProvider.clearAccounts(); - await authProvider.logout(); - } - } - @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return Scaffold( - appBar: AppBar( - actions: [ - if (_showSyncSuccess) - Padding( - padding: const EdgeInsets.only(right: 8), - child: AnimatedOpacity( - opacity: _showSyncSuccess ? 1.0 : 0.0, - duration: const Duration(milliseconds: 300), - child: const Icon( - Icons.cloud_done, - color: Colors.green, - size: 28, - ), - ), - ), - Semantics( - label: 'Open debug logs', - button: true, - child: IconButton( - icon: const Icon(Icons.bug_report), - onPressed: () { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => const LogViewerScreen()), - ); - }, - tooltip: 'Debug Logs', - ), - ), - IconButton( - icon: const Icon(Icons.refresh), - onPressed: _handleRefresh, - tooltip: 'Refresh', - ), - IconButton( - icon: const Icon(Icons.logout), - onPressed: _handleLogout, - tooltip: 'Sign Out', - ), - ], - ), body: Column( children: [ const ConnectivityBanner(), + if (_showSyncSuccess) + AnimatedOpacity( + opacity: _showSyncSuccess ? 1.0 : 0.0, + duration: const Duration(milliseconds: 300), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + color: Colors.green.withValues(alpha: 0.1), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.cloud_done, color: Colors.green, size: 18), + SizedBox(width: 8), + Text( + 'Synced', + style: TextStyle(color: Colors.green, fontSize: 13), + ), + ], + ), + ), + ), Expanded( child: Consumer2( builder: (context, authProvider, accountsProvider, _) { @@ -516,6 +478,8 @@ class DashboardScreenState extends State { }); }, formatAmount: _formatAmount, + netWorthFormatted: accountsProvider.netWorthFormatted, + isStale: accountsProvider.isBalanceSheetStale, ), ), diff --git a/mobile/lib/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart index 04b8e2649..867e22c7b 100644 --- a/mobile/lib/screens/settings_screen.dart +++ b/mobile/lib/screens/settings_screen.dart @@ -3,10 +3,12 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; import '../providers/auth_provider.dart'; +import '../providers/theme_provider.dart'; import '../services/offline_storage_service.dart'; import '../services/log_service.dart'; import '../services/preferences_service.dart'; import '../services/user_service.dart'; +import 'log_viewer_screen.dart'; class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @@ -354,6 +356,22 @@ class _SettingsScreenState extends State { onTap: () => _launchContactUrl(context), ), + Semantics( + label: 'Open debug logs', + button: true, + child: ListTile( + leading: const Icon(Icons.bug_report), + title: const Text('Debug Logs'), + subtitle: const Text('View app diagnostic logs'), + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const LogViewerScreen()), + ); + }, + ), + ), + const Divider(), // Display Settings Section @@ -382,6 +400,37 @@ class _SettingsScreenState extends State { }, ), + Consumer( + builder: (context, themeProvider, _) { + return ListTile( + leading: const Icon(Icons.brightness_6_outlined), + title: const Text('Theme'), + trailing: SegmentedButton( + segments: const [ + ButtonSegment( + value: ThemeMode.light, + icon: Icon(Icons.light_mode, size: 18), + tooltip: 'Light', + ), + ButtonSegment( + value: ThemeMode.system, + icon: Icon(Icons.brightness_auto, size: 18), + tooltip: 'System', + ), + ButtonSegment( + value: ThemeMode.dark, + icon: Icon(Icons.dark_mode, size: 18), + tooltip: 'Dark', + ), + ], + selected: {themeProvider.themeMode}, + onSelectionChanged: (modes) => themeProvider.setThemeMode(modes.first), + showSelectedIcon: false, + ), + ); + }, + ), + const Divider(), // Data Management Section diff --git a/mobile/lib/screens/sso_onboarding_screen.dart b/mobile/lib/screens/sso_onboarding_screen.dart new file mode 100644 index 000000000..3fee35077 --- /dev/null +++ b/mobile/lib/screens/sso_onboarding_screen.dart @@ -0,0 +1,373 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import '../providers/auth_provider.dart'; + +class SsoOnboardingScreen extends StatefulWidget { + const SsoOnboardingScreen({super.key}); + + @override + State createState() => _SsoOnboardingScreenState(); +} + +class _SsoOnboardingScreenState extends State { + bool _showLinkForm = true; + final _linkFormKey = GlobalKey(); + final _createFormKey = GlobalKey(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + final _firstNameController = TextEditingController(); + final _lastNameController = TextEditingController(); + bool _obscurePassword = true; + + @override + void initState() { + super.initState(); + final authProvider = Provider.of(context, listen: false); + _emailController.text = authProvider.ssoEmail ?? ''; + _firstNameController.text = authProvider.ssoFirstName ?? ''; + _lastNameController.text = authProvider.ssoLastName ?? ''; + } + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + _firstNameController.dispose(); + _lastNameController.dispose(); + super.dispose(); + } + + Future _handleLinkAccount() async { + if (!_linkFormKey.currentState!.validate()) return; + + final authProvider = Provider.of(context, listen: false); + await authProvider.ssoLinkAccount( + email: _emailController.text.trim(), + password: _passwordController.text, + ); + } + + Future _handleCreateAccount() async { + if (!_createFormKey.currentState!.validate()) return; + + final authProvider = Provider.of(context, listen: false); + await authProvider.ssoCreateAccount( + firstName: _firstNameController.text.trim(), + lastName: _lastNameController.text.trim(), + ); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + Provider.of(context, listen: false) + .cancelSsoOnboarding(); + }, + ), + title: const Text('Link Your Account'), + ), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Consumer( + builder: (context, authProvider, _) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + SvgPicture.asset( + 'assets/images/google_g_logo.svg', + width: 48, + height: 48, + ), + const SizedBox(height: 16), + Text( + authProvider.ssoEmail != null + ? 'Signed in as ${authProvider.ssoEmail}' + : 'Google account verified', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 24), + + // Error message + if (authProvider.errorMessage != null) + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.error_outline, color: colorScheme.error), + const SizedBox(width: 12), + Expanded( + child: Text( + authProvider.errorMessage!, + style: TextStyle( + color: colorScheme.onErrorContainer), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => authProvider.clearError(), + iconSize: 20, + ), + ], + ), + ), + + // Tab selector + if (authProvider.ssoAllowAccountCreation) ...[ + Container( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest + .withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Expanded( + child: _TabButton( + label: 'Link Existing', + isSelected: _showLinkForm, + onTap: () => + setState(() => _showLinkForm = true), + ), + ), + Expanded( + child: _TabButton( + label: authProvider.ssoHasPendingInvitation + ? 'Accept Invitation' + : 'Create New', + isSelected: !_showLinkForm, + onTap: () => + setState(() => _showLinkForm = false), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + ], + + // Link existing account form + if (_showLinkForm) _buildLinkForm(authProvider, colorScheme), + + // Create new account form + if (!_showLinkForm) + _buildCreateForm(authProvider, colorScheme), + ], + ); + }, + ), + ), + ), + ); + } + + Widget _buildLinkForm(AuthProvider authProvider, ColorScheme colorScheme) { + return Form( + key: _linkFormKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.link, color: colorScheme.primary), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Enter your existing account credentials to link with Google Sign-In.', + style: TextStyle(color: colorScheme.onSurface), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + autocorrect: false, + textInputAction: TextInputAction.next, + decoration: const InputDecoration( + labelText: 'Email', + prefixIcon: Icon(Icons.email_outlined), + ), + validator: (value) { + if (value == null || value.isEmpty) return 'Please enter your email'; + if (!value.contains('@')) return 'Please enter a valid email'; + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _passwordController, + obscureText: _obscurePassword, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + labelText: 'Password', + prefixIcon: const Icon(Icons.lock_outlined), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + onPressed: () { + setState(() => _obscurePassword = !_obscurePassword); + }, + ), + ), + validator: (value) { + if (value == null || value.isEmpty) return 'Please enter your password'; + return null; + }, + onFieldSubmitted: (_) => _handleLinkAccount(), + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: authProvider.isLoading ? null : _handleLinkAccount, + child: authProvider.isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Link Account'), + ), + ], + ), + ); + } + + Widget _buildCreateForm(AuthProvider authProvider, ColorScheme colorScheme) { + final hasPendingInvitation = authProvider.ssoHasPendingInvitation; + return Form( + key: _createFormKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + hasPendingInvitation ? Icons.mail_outline : Icons.person_add, + color: colorScheme.primary, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + hasPendingInvitation + ? 'You have a pending invitation. Accept it to join an existing household.' + : 'Create a new account using your Google identity.', + style: TextStyle(color: colorScheme.onSurface), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + TextFormField( + controller: _firstNameController, + textInputAction: TextInputAction.next, + decoration: const InputDecoration( + labelText: 'First Name', + prefixIcon: Icon(Icons.person_outlined), + ), + validator: (value) { + if (value == null || value.isEmpty) return 'Please enter your first name'; + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _lastNameController, + textInputAction: TextInputAction.done, + decoration: const InputDecoration( + labelText: 'Last Name', + prefixIcon: Icon(Icons.person_outlined), + ), + validator: (value) { + if (value == null || value.isEmpty) return 'Please enter your last name'; + return null; + }, + onFieldSubmitted: (_) => _handleCreateAccount(), + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: authProvider.isLoading ? null : _handleCreateAccount, + child: authProvider.isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(hasPendingInvitation + ? 'Accept Invitation' + : 'Create Account'), + ), + ], + ), + ); + } +} + +class _TabButton extends StatelessWidget { + final String label; + final bool isSelected; + final VoidCallback onTap; + + const _TabButton({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: isSelected ? colorScheme.primary : Colors.transparent, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + label, + textAlign: TextAlign.center, + style: TextStyle( + color: isSelected ? colorScheme.onPrimary : colorScheme.onSurface, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + ), + ), + ), + ); + } +} diff --git a/mobile/lib/services/auth_service.dart b/mobile/lib/services/auth_service.dart index 98a98f31e..e31c34554 100644 --- a/mobile/lib/services/auth_service.dart +++ b/mobile/lib/services/auth_service.dart @@ -364,6 +364,20 @@ class AuthService { Future> handleSsoCallback(Uri uri) async { final params = uri.queryParameters; + // Handle account not linked - return linking data for onboarding flow + if (params['status'] == 'account_not_linked') { + return { + 'success': false, + 'account_not_linked': true, + 'linking_code': params['linking_code'] ?? '', + 'email': params['email'] ?? '', + 'first_name': params['first_name'] ?? '', + 'last_name': params['last_name'] ?? '', + 'allow_account_creation': params['allow_account_creation'] == 'true', + 'has_pending_invitation': params['has_pending_invitation'] == 'true', + }; + } + if (params.containsKey('error')) { return { 'success': false, @@ -440,6 +454,116 @@ class AuthService { } } + Future> ssoLink({ + required String linkingCode, + required String email, + required String password, + }) async { + try { + final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/auth/sso_link'); + final response = await http.post( + url, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: jsonEncode({ + 'linking_code': linkingCode, + 'email': email, + 'password': password, + }), + ).timeout(const Duration(seconds: 30)); + + final responseData = jsonDecode(response.body); + + if (response.statusCode == 200) { + final tokens = AuthTokens.fromJson(responseData); + await _saveTokens(tokens); + + User? user; + if (responseData['user'] != null) { + _logRawUserPayload('sso_link', responseData['user']); + user = User.fromJson(responseData['user']); + await _saveUser(user); + } + + return { + 'success': true, + 'tokens': tokens, + 'user': user, + }; + } else { + return { + 'success': false, + 'error': responseData['error'] ?? responseData['errors']?.join(', ') ?? 'Account linking failed', + }; + } + } on SocketException { + return {'success': false, 'error': 'Network unavailable'}; + } on TimeoutException { + return {'success': false, 'error': 'Request timed out'}; + } catch (e, stackTrace) { + LogService.instance.error('AuthService', 'SSO link error: $e\n$stackTrace'); + return {'success': false, 'error': 'Failed to link account'}; + } + } + + Future> ssoCreateAccount({ + required String linkingCode, + String? firstName, + String? lastName, + }) async { + try { + final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/auth/sso_create_account'); + final body = { + 'linking_code': linkingCode, + }; + if (firstName != null) body['first_name'] = firstName; + if (lastName != null) body['last_name'] = lastName; + + final response = await http.post( + url, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: jsonEncode(body), + ).timeout(const Duration(seconds: 30)); + + final responseData = jsonDecode(response.body); + + if (response.statusCode == 200) { + final tokens = AuthTokens.fromJson(responseData); + await _saveTokens(tokens); + + User? user; + if (responseData['user'] != null) { + _logRawUserPayload('sso_create_account', responseData['user']); + user = User.fromJson(responseData['user']); + await _saveUser(user); + } + + return { + 'success': true, + 'tokens': tokens, + 'user': user, + }; + } else { + return { + 'success': false, + 'error': responseData['error'] ?? responseData['errors']?.join(', ') ?? 'Account creation failed', + }; + } + } on SocketException { + return {'success': false, 'error': 'Network unavailable'}; + } on TimeoutException { + return {'success': false, 'error': 'Request timed out'}; + } catch (e, stackTrace) { + LogService.instance.error('AuthService', 'SSO create account error: $e\n$stackTrace'); + return {'success': false, 'error': 'Failed to create account'}; + } + } + Future> enableAi({ required String accessToken, }) async { diff --git a/mobile/lib/services/balance_sheet_service.dart b/mobile/lib/services/balance_sheet_service.dart new file mode 100644 index 000000000..f143f0587 --- /dev/null +++ b/mobile/lib/services/balance_sheet_service.dart @@ -0,0 +1,51 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'api_config.dart'; + +/// Service for fetching balance sheet data (net worth, assets, liabilities) +/// from the Sure API. +class BalanceSheetService { + /// Fetches the family's balance sheet from GET /api/v1/balance_sheet. + /// + /// Returns a map with 'success' flag and balance sheet fields on success, + /// or 'error' message on failure. + Future> getBalanceSheet({ + required String accessToken, + }) async { + try { + final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/balance_sheet'); + + final response = await http.get( + url, + headers: ApiConfig.getAuthHeaders(accessToken), + ).timeout(const Duration(seconds: 30)); + + if (response.statusCode == 200) { + final responseData = jsonDecode(response.body); + + return { + 'success': true, + 'currency': responseData['currency'] as String?, + 'net_worth': responseData['net_worth'], + 'assets': responseData['assets'], + 'liabilities': responseData['liabilities'], + }; + } else if (response.statusCode == 401) { + return { + 'success': false, + 'error': 'unauthorized', + }; + } else { + return { + 'success': false, + 'error': 'Failed to fetch balance sheet', + }; + } + } catch (e) { + return { + 'success': false, + 'error': 'Unable to load balance sheet. Please try again later.', + }; + } + } +} diff --git a/mobile/lib/services/preferences_service.dart b/mobile/lib/services/preferences_service.dart index 15558385e..35b671e71 100644 --- a/mobile/lib/services/preferences_service.dart +++ b/mobile/lib/services/preferences_service.dart @@ -2,6 +2,7 @@ import 'package:shared_preferences/shared_preferences.dart'; class PreferencesService { static const _groupByTypeKey = 'dashboard_group_by_type'; + static const _themeModeKey = 'theme_mode'; static PreferencesService? _instance; SharedPreferences? _prefs; @@ -27,4 +28,15 @@ class PreferencesService { final prefs = await _preferences; await prefs.setBool(_groupByTypeKey, value); } + + /// Returns 'light', 'dark', or 'system' (default). + Future getThemeMode() async { + final prefs = await _preferences; + return prefs.getString(_themeModeKey) ?? 'system'; + } + + Future setThemeMode(String mode) async { + final prefs = await _preferences; + await prefs.setString(_themeModeKey, mode); + } } diff --git a/mobile/lib/widgets/net_worth_card.dart b/mobile/lib/widgets/net_worth_card.dart index 241a7b91e..2d0ca03a0 100644 --- a/mobile/lib/widgets/net_worth_card.dart +++ b/mobile/lib/widgets/net_worth_card.dart @@ -8,6 +8,8 @@ class NetWorthCard extends StatelessWidget { final AccountFilter currentFilter; final ValueChanged onFilterChanged; final String Function(String currency, double amount) formatAmount; + final String? netWorthFormatted; + final bool isStale; const NetWorthCard({ super.key, @@ -16,6 +18,8 @@ class NetWorthCard extends StatelessWidget { required this.currentFilter, required this.onFilterChanged, required this.formatAmount, + this.netWorthFormatted, + this.isStale = false, }); @override @@ -33,14 +37,49 @@ class NetWorthCard extends StatelessWidget { ), child: Column( children: [ - // Net Worth Section (Placeholder) + // Net Worth Section Padding( padding: const EdgeInsets.fromLTRB(16, 14, 16, 12), - child: Text( - 'Net Worth — coming soon', - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Net Worth', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + if (isStale) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: colorScheme.secondaryContainer.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'Outdated', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.secondary, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 4), + Text( + netWorthFormatted ?? '--', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: isStale ? colorScheme.secondary : colorScheme.onSurface, + ), + ), + ], ), ), diff --git a/mobile/lib/widgets/typing_indicator.dart b/mobile/lib/widgets/typing_indicator.dart new file mode 100644 index 000000000..f23725b33 --- /dev/null +++ b/mobile/lib/widgets/typing_indicator.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; + +/// Animated 3-dot "Thinking..." indicator shown while the AI generates a response. +/// Each dot bounces up in sequence, giving the classic chat typing indicator feel. +class TypingIndicator extends StatefulWidget { + const TypingIndicator({super.key}); + + @override + State createState() => _TypingIndicatorState(); +} + +class _TypingIndicatorState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 1200), + vsync: this, + )..repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final dotColor = colorScheme.onSurfaceVariant; + + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + 'Thinking', + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + const SizedBox(width: 6), + SizedBox( + height: 20, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: List.generate(3, (index) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + final offset = _dotOffset(index, _controller.value); + return Padding( + padding: EdgeInsets.only(right: index < 2 ? 5 : 0), + child: Transform.translate( + offset: Offset(0, offset), + child: Container( + width: 7, + height: 7, + decoration: BoxDecoration( + color: dotColor.withValues(alpha: 0.75), + shape: BoxShape.circle, + ), + ), + ), + ); + }, + ); + }), + ), + ), + ], + ); + } + + /// Returns the vertical offset (px) for a dot at [index] given the + /// controller's current [value] in [0, 1). + /// Each dot is delayed by 1/3 of the cycle so they bounce in sequence. + double _dotOffset(int index, double value) { + const bounceHeight = 5.0; + const dotCount = 3; + final phase = (value - index / dotCount + 1.0) % 1.0; + + // Bounce occupies the first 40% of each dot's phase; rest is idle. + if (phase < 0.2) { + // Rising: 0 → peak + return -bounceHeight * (phase / 0.2); + } else if (phase < 0.4) { + // Falling: peak → 0 + return -bounceHeight * (1.0 - (phase - 0.2) / 0.2); + } + return 0.0; + } +} diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 00d82483b..b81f489e0 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -1,7 +1,7 @@ name: sure_mobile description: A mobile app for Sure personal finance management publish_to: 'none' -version: 0.6.8+11 +version: 0.6.9+20260402 environment: sdk: '>=3.0.0 <4.0.0' diff --git a/pipelock.example.yaml b/pipelock.example.yaml new file mode 100644 index 000000000..2458f834f --- /dev/null +++ b/pipelock.example.yaml @@ -0,0 +1,72 @@ +# Pipelock configuration for Docker Compose +# See https://github.com/luckyPipewrench/pipelock for full options. +# +# New in v2.0: trusted_domains, redirect profiles, attack simulation, +# security scoring, process sandbox, and enhanced tool poisoning detection. +# Run `pipelock simulate --config ` to test your config against 24 attack scenarios. +# Run `pipelock audit score --config ` for a security posture score (0-100). + +version: 1 +mode: balanced + +# Trusted domains: allow services whose public DNS resolves to private IPs. +# Prevents SSRF scanner from blocking legitimate internal traffic. +# trusted_domains: +# - "api.internal.example.com" +# - "*.corp.example.com" + +forward_proxy: + enabled: true + max_tunnel_seconds: 300 + idle_timeout_seconds: 60 + +websocket_proxy: + enabled: false + max_message_bytes: 1048576 + max_concurrent_connections: 128 + scan_text_frames: true + allow_binary_frames: false + forward_cookies: false + strip_compression: true + max_connection_seconds: 3600 + idle_timeout_seconds: 300 + origin_policy: rewrite + +dlp: + scan_env: true + include_defaults: true + +response_scanning: + enabled: true + action: warn + include_defaults: true + +mcp_input_scanning: + enabled: true + action: block + on_parse_error: block + +mcp_tool_scanning: + enabled: true + action: warn + detect_drift: true + +mcp_tool_policy: + enabled: false + action: warn + # Redirect profiles (v2.0): route matched tool calls to audited handler programs + # instead of blocking. The handler returns a synthetic MCP response. + # redirect_profiles: + # safe-fetch: + # exec: ["/pipelock", "internal-redirect", "fetch-proxy"] + # reason: "Route fetch calls through audited proxy" + +mcp_session_binding: + enabled: true + unknown_tool_action: warn + +tool_chain_detection: + enabled: true + action: warn + window_size: 20 + max_gap: 3 diff --git a/public/android-chrome-192x192.png b/public/android-chrome-192x192.png index cadde8cd2..b456e5ca8 100644 Binary files a/public/android-chrome-192x192.png and b/public/android-chrome-192x192.png differ diff --git a/public/logo-pwa.png b/public/logo-pwa.png index e4669cd4c..d387ae48e 100644 Binary files a/public/logo-pwa.png and b/public/logo-pwa.png differ diff --git a/spec/requests/api/v1/auth_spec.rb b/spec/requests/api/v1/auth_spec.rb index f21ab79f2..278ec7348 100644 --- a/spec/requests/api/v1/auth_spec.rb +++ b/spec/requests/api/v1/auth_spec.rb @@ -216,6 +216,124 @@ RSpec.describe 'API V1 Auth', type: :request do end end + path '/api/v1/auth/sso_link' do + post 'Link an existing account via SSO' do + tags 'Auth' + consumes 'application/json' + produces 'application/json' + description 'Authenticates with email/password and links the SSO identity from a previously issued linking code. Creates an OidcIdentity, logs the link via SsoAuditLog, and issues mobile OAuth tokens.' + parameter name: :body, in: :body, required: true, schema: { + type: :object, + properties: { + linking_code: { type: :string, description: 'One-time linking code from mobile SSO onboarding redirect' }, + email: { type: :string, format: :email, description: 'Email of the existing account to link' }, + password: { type: :string, description: 'Password for the existing account' } + }, + required: %w[linking_code email password] + } + + response '200', 'account linked and tokens issued' do + schema type: :object, + properties: { + access_token: { type: :string }, + refresh_token: { type: :string }, + token_type: { type: :string }, + expires_in: { type: :integer }, + created_at: { type: :integer }, + user: { + type: :object, + properties: { + id: { type: :string, format: :uuid }, + email: { type: :string }, + first_name: { type: :string }, + last_name: { type: :string }, + ui_layout: { type: :string, enum: %w[dashboard intro] }, + ai_enabled: { type: :boolean } + } + } + } + run_test! + end + + response '400', 'missing linking code' do + schema '$ref' => '#/components/schemas/ErrorResponse' + run_test! + end + + response '401', 'invalid credentials or expired linking code' do + schema '$ref' => '#/components/schemas/ErrorResponse' + run_test! + end + end + end + + path '/api/v1/auth/sso_create_account' do + post 'Create a new account via SSO' do + tags 'Auth' + consumes 'application/json' + produces 'application/json' + description 'Creates a new user and family from a previously issued linking code. Links the SSO identity via OidcIdentity, logs the JIT account creation via SsoAuditLog, and issues mobile OAuth tokens. The linking code must have allow_account_creation enabled.' + parameter name: :body, in: :body, required: true, schema: { + type: :object, + properties: { + linking_code: { type: :string, description: 'One-time linking code from mobile SSO onboarding redirect' }, + first_name: { type: :string, description: 'First name (overrides value from SSO provider if provided)' }, + last_name: { type: :string, description: 'Last name (overrides value from SSO provider if provided)' } + }, + required: %w[linking_code] + } + + response '200', 'account created and tokens issued' do + schema type: :object, + properties: { + access_token: { type: :string }, + refresh_token: { type: :string }, + token_type: { type: :string }, + expires_in: { type: :integer }, + created_at: { type: :integer }, + user: { + type: :object, + properties: { + id: { type: :string, format: :uuid }, + email: { type: :string }, + first_name: { type: :string }, + last_name: { type: :string }, + ui_layout: { type: :string, enum: %w[dashboard intro] }, + ai_enabled: { type: :boolean } + } + } + } + run_test! + end + + response '400', 'missing linking code' do + schema '$ref' => '#/components/schemas/ErrorResponse' + run_test! + end + + response '401', 'invalid or expired linking code' do + schema '$ref' => '#/components/schemas/ErrorResponse' + run_test! + end + + response '403', 'account creation disabled' do + schema '$ref' => '#/components/schemas/ErrorResponse' + run_test! + end + + response '422', 'user validation error' do + schema type: :object, + properties: { + errors: { + type: :array, + items: { type: :string } + } + } + run_test! + end + end + end + path '/api/v1/auth/enable_ai' do patch 'Enable AI features for the authenticated user' do tags 'Auth' diff --git a/spec/requests/api/v1/balance_sheet_spec.rb b/spec/requests/api/v1/balance_sheet_spec.rb new file mode 100644 index 000000000..72132871a --- /dev/null +++ b/spec/requests/api/v1/balance_sheet_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'API V1 Balance Sheet', type: :request do + let(:family) do + Family.create!( + name: 'API Family', + currency: 'USD', + locale: 'en', + date_format: '%m-%d-%Y' + ) + end + + let(:user) do + family.users.create!( + email: 'api-user@example.com', + password: 'password123', + password_confirmation: 'password123' + ) + end + + let(:api_key) do + key = ApiKey.generate_secure_key + ApiKey.create!( + user: user, + name: 'API Docs Key', + key: key, + scopes: %w[read_write], + source: 'web' + ) + end + + let(:'X-Api-Key') { api_key.plain_key } + + path '/api/v1/balance_sheet' do + get 'Show balance sheet' do + tags 'Balance Sheet' + description 'Returns the family balance sheet including net worth, total assets, and total liabilities ' \ + 'with amounts converted to the family\'s primary currency.' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + + response '200', 'balance sheet returned' do + schema '$ref' => '#/components/schemas/BalanceSheet' + + run_test! + end + + response '401', 'unauthorized' do + let(:'X-Api-Key') { 'invalid-key' } + + run_test! + end + end + end +end diff --git a/spec/requests/api/v1/categories_spec.rb b/spec/requests/api/v1/categories_spec.rb index 6868e3979..90e06d5c2 100644 --- a/spec/requests/api/v1/categories_spec.rb +++ b/spec/requests/api/v1/categories_spec.rb @@ -36,7 +36,6 @@ RSpec.describe 'API V1 Categories', type: :request do let!(:parent_category) do family.categories.create!( name: 'Food & Drink', - classification: 'expense', color: '#f97316', lucide_icon: 'utensils' ) @@ -45,7 +44,6 @@ RSpec.describe 'API V1 Categories', type: :request do let!(:subcategory) do family.categories.create!( name: 'Restaurants', - classification: 'expense', color: '#f97316', lucide_icon: 'utensils', parent: parent_category @@ -55,7 +53,6 @@ RSpec.describe 'API V1 Categories', type: :request do let!(:income_category) do family.categories.create!( name: 'Salary', - classification: 'income', color: '#22c55e', lucide_icon: 'circle-dollar-sign' ) @@ -70,9 +67,6 @@ RSpec.describe 'API V1 Categories', type: :request do description: 'Page number (default: 1)' parameter name: :per_page, in: :query, type: :integer, required: false, description: 'Items per page (default: 25, max: 100)' - parameter name: :classification, in: :query, required: false, - description: 'Filter by classification (income or expense)', - schema: { type: :string, enum: %w[income expense] } parameter name: :roots_only, in: :query, required: false, description: 'Return only root categories (no parent)', schema: { type: :boolean } @@ -86,14 +80,6 @@ RSpec.describe 'API V1 Categories', type: :request do run_test! end - response '200', 'categories filtered by classification' do - schema '$ref' => '#/components/schemas/CategoryCollection' - - let(:classification) { 'expense' } - - run_test! - end - response '200', 'root categories only' do schema '$ref' => '#/components/schemas/CategoryCollection' diff --git a/spec/requests/api/v1/trades_spec.rb b/spec/requests/api/v1/trades_spec.rb index 1eac1032d..97a40a03a 100644 --- a/spec/requests/api/v1/trades_spec.rb +++ b/spec/requests/api/v1/trades_spec.rb @@ -54,7 +54,6 @@ RSpec.describe 'API V1 Trades', type: :request do let(:category) do family.categories.create!( name: 'Investments', - classification: 'expense', color: '#2196F3', lucide_icon: 'trending-up' ) diff --git a/spec/requests/api/v1/transactions_spec.rb b/spec/requests/api/v1/transactions_spec.rb index 5e114c705..a4eab7590 100644 --- a/spec/requests/api/v1/transactions_spec.rb +++ b/spec/requests/api/v1/transactions_spec.rb @@ -46,7 +46,6 @@ RSpec.describe 'API V1 Transactions', type: :request do let(:category) do family.categories.create!( name: 'Groceries', - classification: 'expense', color: '#4CAF50', lucide_icon: 'shopping-cart' ) diff --git a/spec/requests/api/v1/users_spec.rb b/spec/requests/api/v1/users_spec.rb index 65c745ede..bb714d8ef 100644 --- a/spec/requests/api/v1/users_spec.rb +++ b/spec/requests/api/v1/users_spec.rb @@ -12,11 +12,14 @@ RSpec.describe 'API V1 Users', type: :request do ) end + let(:role) { :admin } + let(:user) do family.users.create!( email: 'api-user@example.com', password: 'password123', - password_confirmation: 'password123' + password_confirmation: 'password123', + role: role ) end @@ -38,7 +41,8 @@ RSpec.describe 'API V1 Users', type: :request do tags 'Users' description 'Resets all financial data (accounts, categories, merchants, tags, etc.) ' \ 'for the current user\'s family while keeping the user account intact. ' \ - 'The reset runs asynchronously in the background.' + 'The reset runs asynchronously in the background. ' \ + 'Requires admin role.' security [ { apiKeyAuth: [] } ] produces 'application/json' @@ -54,7 +58,7 @@ RSpec.describe 'API V1 Users', type: :request do run_test! end - response '403', 'insufficient scope' do + response '403', 'forbidden - requires read_write scope and admin role' do let(:api_key) do key = ApiKey.generate_secure_key ApiKey.create!( diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb index c02b0831c..ad99750c1 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -203,11 +203,10 @@ RSpec.configure do |config| }, Category: { type: :object, - required: %w[id name classification color icon], + required: %w[id name color icon], properties: { id: { type: :string, format: :uuid }, name: { type: :string }, - classification: { type: :string }, color: { type: :string }, icon: { type: :string } } @@ -222,11 +221,10 @@ RSpec.configure do |config| }, CategoryDetail: { type: :object, - required: %w[id name classification color icon subcategories_count created_at updated_at], + required: %w[id name color icon subcategories_count created_at updated_at], properties: { id: { type: :string, format: :uuid }, name: { type: :string }, - classification: { type: :string, enum: %w[income expense] }, color: { type: :string }, icon: { type: :string }, parent: { '$ref' => '#/components/schemas/CategoryParent', nullable: true }, @@ -518,6 +516,25 @@ RSpec.configure do |config| pagination: { '$ref' => '#/components/schemas/Pagination' } } }, + Money: { + type: :object, + required: %w[amount currency formatted], + properties: { + amount: { type: :string, description: 'Numeric amount as string' }, + currency: { type: :string, description: 'ISO 4217 currency code' }, + formatted: { type: :string, description: 'Locale-formatted money string' } + } + }, + BalanceSheet: { + type: :object, + required: %w[currency net_worth assets liabilities], + properties: { + currency: { type: :string, description: 'Family primary currency' }, + net_worth: { '$ref' => '#/components/schemas/Money' }, + assets: { '$ref' => '#/components/schemas/Money' }, + liabilities: { '$ref' => '#/components/schemas/Money' } + } + }, SuccessMessage: { type: :object, required: %w[message], diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb index a32c04b49..69724cda0 100644 --- a/test/application_system_test_case.rb +++ b/test/application_system_test_case.rb @@ -1,11 +1,38 @@ require "test_helper" +require "socket" class ApplicationSystemTestCase < ActionDispatch::SystemTestCase setup do Capybara.default_max_wait_time = 5 + + if ENV["SELENIUM_REMOTE_URL"].present? + server_port = ENV.fetch("CAPYBARA_SERVER_PORT", 30_000 + (Process.pid % 1000)).to_i + app_host = ENV["CAPYBARA_APP_HOST"].presence || IPSocket.getaddress(Socket.gethostname) + + Capybara.server_host = "0.0.0.0" + Capybara.server_port = server_port + Capybara.always_include_port = true + Capybara.app_host = "http://#{app_host}:#{server_port}" + end end - driven_by :selenium, using: ENV["CI"].present? ? :headless_chrome : ENV.fetch("E2E_BROWSER", :chrome).to_sym, screen_size: [ 1400, 1400 ] + if ENV["SELENIUM_REMOTE_URL"].present? + Capybara.register_driver :selenium_remote_chrome do |app| + options = Selenium::WebDriver::Chrome::Options.new + options.add_argument("--window-size=1400,1400") + + Capybara::Selenium::Driver.new( + app, + browser: :remote, + url: ENV["SELENIUM_REMOTE_URL"], + capabilities: options + ) + end + + driven_by :selenium_remote_chrome, screen_size: [ 1400, 1400 ] + else + driven_by :selenium, using: ENV["CI"].present? ? :headless_chrome : ENV.fetch("E2E_BROWSER", :chrome).to_sym, screen_size: [ 1400, 1400 ] + end private diff --git a/test/controllers/accounts_controller_test.rb b/test/controllers/accounts_controller_test.rb index c192c278b..0034360e0 100644 --- a/test/controllers/accounts_controller_test.rb +++ b/test/controllers/accounts_controller_test.rb @@ -16,6 +16,27 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest assert_response :success end + test "activity pagination keeps activity tab when loaded from holdings tab" do + investment = accounts(:investment) + + 11.times do |i| + Entry.create!( + account: investment, + name: "Test investment activity #{i}", + date: Date.current - i.days, + amount: 10 + i, + currency: investment.currency, + entryable: Transaction.new + ) + end + + get account_url(investment, tab: "holdings") + + assert_response :success + assert_select "a[href*='page=2'][href*='tab=activity']" + assert_select "a[href*='page=2'][href*='tab=holdings']", count: 0 + end + test "should sync account" do post sync_account_url(@account) assert_redirected_to account_url(@account) @@ -137,7 +158,6 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest assert_response :success assert_includes @response.body, @account.name - assert_includes @response.body, "account_#{@account.id}_active" end test "toggle_active disables and re-enables an account" do @@ -157,6 +177,34 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest assert_response :success end + test "set_default sets user default account" do + patch set_default_account_url(@account) + assert_redirected_to accounts_path + @user.reload + assert_equal @account.id, @user.default_account_id + end + + test "set_default rejects ineligible account type" do + investment = accounts(:investment) + + patch set_default_account_url(investment) + assert_redirected_to accounts_path + assert_equal I18n.t("accounts.set_default.depository_only"), flash[:alert] + + @user.reload + assert_not_equal investment.id, @user.default_account_id + end + + test "remove_default clears user default account" do + @user.update!(default_account: @account) + + patch remove_default_account_url(@account) + assert_redirected_to accounts_path + + @user.reload + assert_nil @user.default_account_id + end + test "select_provider redirects for already linked account" do plaid_account = plaid_accounts(:one) AccountProvider.create!(account: @account, provider: plaid_account) diff --git a/test/controllers/admin/users_controller_test.rb b/test/controllers/admin/users_controller_test.rb index e23180e2c..1273c4b18 100644 --- a/test/controllers/admin/users_controller_test.rb +++ b/test/controllers/admin/users_controller_test.rb @@ -5,43 +5,45 @@ class Admin::UsersControllerTest < ActionDispatch::IntegrationTest sign_in users(:sure_support_staff) end - test "index sorts users by subscription trial end date with nils last" do - user_with_trial = User.find_by!(email: "user1@example.com") - user_without_trial = User.find_by!(email: "bob@bobdylan.com") + test "index groups users by family sorted by transaction count" do + family_with_more = users(:family_admin).family + family_with_fewer = users(:empty).family - user_with_trial.family.subscription&.destroy - Subscription.create!( - family_id: user_with_trial.family_id, - status: :trialing, - trial_ends_at: 2.days.from_now - ) - - user_without_trial.family.subscription&.destroy - Subscription.create!( - family_id: user_without_trial.family_id, - status: :active, - trial_ends_at: nil, - stripe_id: "cus_test_#{user_without_trial.family_id}" - ) + account = Account.create!(family: family_with_more, name: "Test", balance: 0, currency: "USD", accountable: Depository.new) + 3.times { |i| account.entries.create!(name: "Txn #{i}", date: Date.current, amount: 10, currency: "USD", entryable: Transaction.new) } get admin_users_url - assert_response :success body = response.body - trial_user_index = body.index("user1@example.com") - no_trial_user_index = body.index("bob@bobdylan.com") + more_idx = body.index(family_with_more.name) + fewer_idx = body.index(family_with_fewer.name) - assert_not_nil trial_user_index - assert_not_nil no_trial_user_index - assert_operator trial_user_index, :<, no_trial_user_index, - "User with trialing subscription (user1@example.com) should appear before user with non-trial subscription (bob@bobdylan.com)" + assert_not_nil more_idx + assert_not_nil fewer_idx + assert_operator more_idx, :<, fewer_idx, + "Family with more transactions should appear before family with fewer" end - test "index shows n/a when trial end date is unavailable" do - get admin_users_url + test "index shows subscription status for families" do + family = users(:family_admin).family + family.subscription&.destroy + Subscription.create!( + family_id: family.id, + status: :active, + stripe_id: "cus_test_#{family.id}" + ) + get admin_users_url assert_response :success - assert_match(/n\/a/, response.body, "Page should show n/a for users without trial end date") + assert_match(/Active/, response.body, "Page should show subscription status for families with active subscriptions") + end + + test "index shows no subscription label for families without subscription" do + users(:family_admin).family.subscription&.destroy + + get admin_users_url + assert_response :success + assert_match(/No subscription/, response.body, "Page should show 'No subscription' for families without one") end end diff --git a/test/controllers/api/v1/auth_controller_test.rb b/test/controllers/api/v1/auth_controller_test.rb index 06b2ed537..52207df3a 100644 --- a/test/controllers/api/v1/auth_controller_test.rb +++ b/test/controllers/api/v1/auth_controller_test.rb @@ -22,6 +22,14 @@ class Api::V1::AuthControllerTest < ActionDispatch::IntegrationTest # Clear the memoized class variable so it picks up the test record MobileDevice.instance_variable_set(:@shared_oauth_application, nil) + + # Use a real cache store for SSO linking tests (test env uses :null_store by default) + @original_cache = Rails.cache + Rails.cache = ActiveSupport::Cache::MemoryStore.new + end + + teardown do + Rails.cache = @original_cache if @original_cache end test "should signup new user and return OAuth tokens" do @@ -488,6 +496,311 @@ class Api::V1::AuthControllerTest < ActionDispatch::IntegrationTest assert_response :unauthorized end + # SSO Link tests + test "should link existing account via SSO and return tokens" do + user = users(:family_admin) + + linking_code = SecureRandom.urlsafe_base64(32) + Rails.cache.write("mobile_sso_link:#{linking_code}", { + provider: "google_oauth2", + uid: "google-uid-123", + email: "google@example.com", + first_name: "Google", + last_name: "User", + name: "Google User", + device_info: @device_info.stringify_keys, + allow_account_creation: true + }, expires_in: 10.minutes) + + assert_difference("OidcIdentity.count", 1) do + post "/api/v1/auth/sso_link", params: { + linking_code: linking_code, + email: user.email, + password: user_password_test + } + end + + assert_response :success + response_data = JSON.parse(response.body) + assert response_data["access_token"].present? + assert response_data["refresh_token"].present? + assert_equal user.id.to_s, response_data["user"]["id"] + + # Linking code should be consumed + assert_nil Rails.cache.read("mobile_sso_link:#{linking_code}") + end + + test "should reject SSO link with invalid password" do + user = users(:family_admin) + + linking_code = SecureRandom.urlsafe_base64(32) + Rails.cache.write("mobile_sso_link:#{linking_code}", { + provider: "google_oauth2", + uid: "google-uid-123", + email: "google@example.com", + device_info: @device_info.stringify_keys, + allow_account_creation: true + }, expires_in: 10.minutes) + + assert_no_difference("OidcIdentity.count") do + post "/api/v1/auth/sso_link", params: { + linking_code: linking_code, + email: user.email, + password: "wrong_password" + } + end + + assert_response :unauthorized + response_data = JSON.parse(response.body) + assert_equal "Invalid email or password", response_data["error"] + + # Linking code should NOT be consumed on failed password + assert Rails.cache.read("mobile_sso_link:#{linking_code}").present?, "Expected linking code to survive a failed attempt" + end + + test "should reject SSO link when user has MFA enabled" do + user = users(:family_admin) + user.update!(otp_required: true, otp_secret: ROTP::Base32.random(32)) + + linking_code = SecureRandom.urlsafe_base64(32) + Rails.cache.write("mobile_sso_link:#{linking_code}", { + provider: "google_oauth2", + uid: "google-uid-mfa", + email: "mfa@example.com", + first_name: "MFA", + last_name: "User", + name: "MFA User", + device_info: @device_info.stringify_keys, + allow_account_creation: true + }, expires_in: 10.minutes) + + assert_no_difference("OidcIdentity.count") do + post "/api/v1/auth/sso_link", params: { + linking_code: linking_code, + email: user.email, + password: user_password_test + } + end + + assert_response :unauthorized + response_data = JSON.parse(response.body) + assert_equal true, response_data["mfa_required"] + assert_match(/MFA/, response_data["error"]) + + # Linking code should NOT be consumed on MFA rejection + assert Rails.cache.read("mobile_sso_link:#{linking_code}").present?, "Expected linking code to survive MFA rejection" + end + + test "should reject SSO link with expired linking code" do + post "/api/v1/auth/sso_link", params: { + linking_code: "expired-code", + email: "test@example.com", + password: "password" + } + + assert_response :unauthorized + response_data = JSON.parse(response.body) + assert_equal "Linking code is invalid or expired", response_data["error"] + end + + test "should reject SSO link without linking code" do + post "/api/v1/auth/sso_link", params: { + email: "test@example.com", + password: "password" + } + + assert_response :bad_request + response_data = JSON.parse(response.body) + assert_equal "Linking code is required", response_data["error"] + end + + test "linking_code is single-use under race" do + user = users(:family_admin) + + linking_code = SecureRandom.urlsafe_base64(32) + Rails.cache.write("mobile_sso_link:#{linking_code}", { + provider: "google_oauth2", + uid: "google-uid-race-test", + email: "race@example.com", + first_name: "Race", + last_name: "Test", + name: "Race Test", + device_info: @device_info.stringify_keys, + allow_account_creation: true + }, expires_in: 10.minutes) + + # First request succeeds + assert_difference("OidcIdentity.count", 1) do + post "/api/v1/auth/sso_link", params: { + linking_code: linking_code, + email: user.email, + password: user_password_test + } + end + assert_response :success + + # Second request with the same code is rejected + assert_no_difference("OidcIdentity.count") do + post "/api/v1/auth/sso_link", params: { + linking_code: linking_code, + email: user.email, + password: user_password_test + } + end + assert_response :unauthorized + assert_equal "Linking code is invalid or expired", JSON.parse(response.body)["error"] + assert_nil Rails.cache.read("mobile_sso_link:#{linking_code}") + end + + # SSO Create Account tests + test "should create new account via SSO and return tokens" do + linking_code = SecureRandom.urlsafe_base64(32) + Rails.cache.write("mobile_sso_link:#{linking_code}", { + provider: "google_oauth2", + uid: "google-uid-456", + email: "newgoogleuser@example.com", + first_name: "New", + last_name: "GoogleUser", + name: "New GoogleUser", + device_info: @device_info.stringify_keys, + allow_account_creation: true + }, expires_in: 10.minutes) + + assert_difference([ "User.count", "OidcIdentity.count" ], 1) do + post "/api/v1/auth/sso_create_account", params: { + linking_code: linking_code, + first_name: "New", + last_name: "GoogleUser" + } + end + + assert_response :success + response_data = JSON.parse(response.body) + assert response_data["access_token"].present? + assert response_data["refresh_token"].present? + assert_equal "newgoogleuser@example.com", response_data["user"]["email"] + assert_equal "New", response_data["user"]["first_name"] + assert_equal "GoogleUser", response_data["user"]["last_name"] + + # Linking code should be consumed + assert_nil Rails.cache.read("mobile_sso_link:#{linking_code}") + end + + test "should reject SSO create account when not allowed" do + linking_code = SecureRandom.urlsafe_base64(32) + Rails.cache.write("mobile_sso_link:#{linking_code}", { + provider: "google_oauth2", + uid: "google-uid-789", + email: "blocked@example.com", + first_name: "Blocked", + last_name: "User", + device_info: @device_info.stringify_keys, + allow_account_creation: false + }, expires_in: 10.minutes) + + assert_no_difference("User.count") do + post "/api/v1/auth/sso_create_account", params: { + linking_code: linking_code, + first_name: "Blocked", + last_name: "User" + } + end + + assert_response :forbidden + response_data = JSON.parse(response.body) + assert_match(/disabled/, response_data["error"]) + + # Linking code should NOT be consumed on rejection + assert Rails.cache.read("mobile_sso_link:#{linking_code}").present?, "Expected linking code to survive a rejected create account attempt" + end + + test "should reject SSO create account with expired linking code" do + post "/api/v1/auth/sso_create_account", params: { + linking_code: "expired-code", + first_name: "Test", + last_name: "User" + } + + assert_response :unauthorized + response_data = JSON.parse(response.body) + assert_equal "Linking code is invalid or expired", response_data["error"] + end + + test "should reject SSO create account without linking code" do + post "/api/v1/auth/sso_create_account", params: { + first_name: "Test", + last_name: "User" + } + + assert_response :bad_request + response_data = JSON.parse(response.body) + assert_equal "Linking code is required", response_data["error"] + end + + test "should return 422 when SSO create account fails user validation" do + existing_user = users(:family_admin) + + linking_code = SecureRandom.urlsafe_base64(32) + Rails.cache.write("mobile_sso_link:#{linking_code}", { + provider: "google_oauth2", + uid: "google-uid-dup-email", + email: existing_user.email, + first_name: "Duplicate", + last_name: "Email", + name: "Duplicate Email", + device_info: @device_info.stringify_keys, + allow_account_creation: true + }, expires_in: 10.minutes) + + assert_no_difference([ "User.count", "OidcIdentity.count" ]) do + post "/api/v1/auth/sso_create_account", params: { + linking_code: linking_code, + first_name: "Duplicate", + last_name: "Email" + } + end + + assert_response :unprocessable_entity + response_data = JSON.parse(response.body) + assert response_data["errors"].any? { |e| e.match?(/email/i) }, "Expected email validation error in: #{response_data["errors"]}" + end + + test "sso_create_account linking_code single-use under race" do + linking_code = SecureRandom.urlsafe_base64(32) + Rails.cache.write("mobile_sso_link:#{linking_code}", { + provider: "google_oauth2", + uid: "google-uid-race-create", + email: "raceuser@example.com", + first_name: "Race", + last_name: "CreateUser", + name: "Race CreateUser", + device_info: @device_info.stringify_keys, + allow_account_creation: true + }, expires_in: 10.minutes) + + # First request succeeds + assert_difference([ "User.count", "OidcIdentity.count" ], 1) do + post "/api/v1/auth/sso_create_account", params: { + linking_code: linking_code, + first_name: "Race", + last_name: "CreateUser" + } + end + assert_response :success + + # Second request with the same code is rejected + assert_no_difference([ "User.count", "OidcIdentity.count" ]) do + post "/api/v1/auth/sso_create_account", params: { + linking_code: linking_code, + first_name: "Race", + last_name: "CreateUser" + } + end + assert_response :unauthorized + assert_equal "Linking code is invalid or expired", JSON.parse(response.body)["error"] + assert_nil Rails.cache.read("mobile_sso_link:#{linking_code}") + end + test "should return forbidden when ai is not available" do user = users(:family_admin) user.update!(ai_enabled: false) diff --git a/test/controllers/api/v1/balance_sheet_controller_test.rb b/test/controllers/api/v1/balance_sheet_controller_test.rb new file mode 100644 index 000000000..4597a387f --- /dev/null +++ b/test/controllers/api/v1/balance_sheet_controller_test.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "test_helper" + +class Api::V1::BalanceSheetControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:family_admin) + @family = @user.family + + @user.api_keys.active.destroy_all + + @auth = ApiKey.create!( + user: @user, + name: "Test Read Key", + scopes: [ "read" ], + display_key: "test_ro_#{SecureRandom.hex(8)}", + source: "mobile" + ) + + Redis.new.del("api_rate_limit:#{@auth.id}") + end + + test "should require authentication" do + get "/api/v1/balance_sheet" + assert_response :unauthorized + end + + test "should return balance sheet with net worth data" do + get "/api/v1/balance_sheet", headers: api_headers(@auth) + + assert_response :success + response_body = JSON.parse(response.body) + + assert response_body.key?("currency") + assert response_body.key?("net_worth") + assert response_body.key?("assets") + assert response_body.key?("liabilities") + + %w[net_worth assets liabilities].each do |field| + assert response_body[field].key?("amount"), "#{field} should have amount" + assert response_body[field].key?("currency"), "#{field} should have currency" + assert response_body[field].key?("formatted"), "#{field} should have formatted" + end + end + + private + + def api_headers(auth) + { "X-Api-Key" => auth.display_key } + end +end diff --git a/test/controllers/api/v1/categories_controller_test.rb b/test/controllers/api/v1/categories_controller_test.rb index 9f4e87630..c60fcacc1 100644 --- a/test/controllers/api/v1/categories_controller_test.rb +++ b/test/controllers/api/v1/categories_controller_test.rb @@ -84,7 +84,7 @@ class Api::V1::CategoriesControllerTest < ActionDispatch::IntegrationTest category = response_body["categories"].find { |c| c["name"] == @category.name } assert category.present?, "Should find the food_and_drink category" - required_fields = %w[id name classification color icon subcategories_count created_at updated_at] + required_fields = %w[id name color icon subcategories_count created_at updated_at] required_fields.each do |field| assert category.key?(field), "Category should have #{field} field" end @@ -124,19 +124,6 @@ class Api::V1::CategoriesControllerTest < ActionDispatch::IntegrationTest assert_equal 2, response_body["pagination"]["per_page"] end - test "should filter by classification" do - get "/api/v1/categories", params: { classification: "expense" }, headers: { - "Authorization" => "Bearer #{@access_token.token}" - } - - assert_response :success - response_body = JSON.parse(response.body) - - response_body["categories"].each do |category| - assert_equal "expense", category["classification"] - end - end - test "should filter for roots only" do get "/api/v1/categories", params: { roots_only: true }, headers: { "Authorization" => "Bearer #{@access_token.token}" @@ -174,7 +161,6 @@ class Api::V1::CategoriesControllerTest < ActionDispatch::IntegrationTest assert_equal @category.id, response_body["id"] assert_equal @category.name, response_body["name"] - assert_equal @category.classification, response_body["classification"] assert_equal @category.color, response_body["color"] assert_equal @category.lucide_icon, response_body["icon"] end @@ -190,7 +176,11 @@ class Api::V1::CategoriesControllerTest < ActionDispatch::IntegrationTest end test "should not return category from another family" do - other_family_category = categories(:one) # belongs to :empty family + other_family_category = families(:empty).categories.create!( + name: "Other Family Category", + color: "#FF0000", + classification_unused: "expense" + ) get "/api/v1/categories/#{other_family_category.id}", params: {}, headers: { "Authorization" => "Bearer #{@access_token.token}" diff --git a/test/controllers/api/v1/imports_controller_test.rb b/test/controllers/api/v1/imports_controller_test.rb index ec01ca7ba..fb5ac9bc2 100644 --- a/test/controllers/api/v1/imports_controller_test.rb +++ b/test/controllers/api/v1/imports_controller_test.rb @@ -2,15 +2,34 @@ require "test_helper" class Api::V1::ImportsControllerTest < ActionDispatch::IntegrationTest setup do - @family = families(:dylan_family) @user = users(:family_admin) + @family = @user.family @account = accounts(:depository) @import = imports(:transaction) - @token = valid_token_for(@user) + + @user.api_keys.active.destroy_all + + @api_key = ApiKey.create!( + user: @user, + name: "Test Read-Write Key", + scopes: [ "read_write" ], + display_key: "test_rw_#{SecureRandom.hex(8)}" + ) + + @read_only_api_key = ApiKey.create!( + user: @user, + name: "Test Read-Only Key", + scopes: [ "read" ], + display_key: "test_ro_#{SecureRandom.hex(8)}", + source: "mobile" + ) + + Redis.new.del("api_rate_limit:#{@api_key.id}") + Redis.new.del("api_rate_limit:#{@read_only_api_key.id}") end test "should list imports" do - get api_v1_imports_url, headers: { Authorization: "Bearer #{@token}" } + get api_v1_imports_url, headers: api_headers(@api_key) assert_response :success json_response = JSON.parse(response.body) @@ -19,7 +38,7 @@ class Api::V1::ImportsControllerTest < ActionDispatch::IntegrationTest end test "should show import" do - get api_v1_import_url(@import), headers: { Authorization: "Bearer #{@token}" } + get api_v1_import_url(@import), headers: api_headers(@api_key) assert_response :success json_response = JSON.parse(response.body) @@ -39,7 +58,7 @@ class Api::V1::ImportsControllerTest < ActionDispatch::IntegrationTest name_col_label: "name", account_id: @account.id }, - headers: { Authorization: "Bearer #{@token}" } + headers: api_headers(@api_key) end assert_response :created @@ -62,7 +81,7 @@ class Api::V1::ImportsControllerTest < ActionDispatch::IntegrationTest name_col_label: "name", account_id: @account.id }, - headers: { Authorization: "Bearer #{@token}" } + headers: api_headers(@api_key) end assert_response :created @@ -74,6 +93,44 @@ class Api::V1::ImportsControllerTest < ActionDispatch::IntegrationTest assert_equal "-10.00", import.rows.first.amount # Normalized end + test "should instantiate RuleImport before generating rows" do + @family.categories.create!( + name: "Groceries", + color: "#407706", + lucide_icon: "shopping-basket" + ) + + csv_content = <<~CSV + name,resource_type,active,effective_date,conditions,actions + "Categorize groceries","transaction",true,2024-01-01,"[{""condition_type"":""transaction_name"",""operator"":""like"",""value"":""grocery""}]","[{""action_type"":""set_transaction_category"",""value"":""Groceries""}]" + CSV + + assert_difference([ "Import.count", "Import::Row.count" ], 1) do + post api_v1_imports_url, + params: { + type: "RuleImport", + raw_file_content: csv_content, + col_sep: "," + }, + headers: api_headers(@api_key) + end + + assert_response :created + + json_response = JSON.parse(response.body) + import = Import.find(json_response["data"]["id"]) + row = import.rows.first + + assert_instance_of RuleImport, import + assert_equal 1, import.rows_count + assert_equal "Categorize groceries", row.name + assert_equal "transaction", row.resource_type + assert_equal true, row.active + assert_equal "2024-01-01", row.effective_date + assert_equal '[{"condition_type":"transaction_name","operator":"like","value":"grocery"}]', row.conditions + assert_equal '[{"action_type":"set_transaction_category","value":"Groceries"}]', row.actions + end + test "should create import and auto-publish when configured and requested" do csv_content = "date,amount,name\n2023-01-01,-10.00,Test Transaction" @@ -88,7 +145,7 @@ class Api::V1::ImportsControllerTest < ActionDispatch::IntegrationTest date_format: "%Y-%m-%d", publish: "true" }, - headers: { Authorization: "Bearer #{@token}" } + headers: api_headers(@api_key) end assert_response :created @@ -108,7 +165,7 @@ class Api::V1::ImportsControllerTest < ActionDispatch::IntegrationTest raw_file_content: csv_content, account_id: other_account.id }, - headers: { Authorization: "Bearer #{@token}" } + headers: api_headers(@api_key) assert_response :unprocessable_entity json_response = JSON.parse(response.body) @@ -125,7 +182,7 @@ class Api::V1::ImportsControllerTest < ActionDispatch::IntegrationTest assert_no_difference("Import.count") do post api_v1_imports_url, params: { file: large_file }, - headers: { Authorization: "Bearer #{@token}" } + headers: api_headers(@api_key) end assert_response :unprocessable_entity @@ -143,7 +200,7 @@ class Api::V1::ImportsControllerTest < ActionDispatch::IntegrationTest assert_no_difference("Import.count") do post api_v1_imports_url, params: { file: invalid_file }, - headers: { Authorization: "Bearer #{@token}" } + headers: api_headers(@api_key) end assert_response :unprocessable_entity @@ -163,7 +220,7 @@ class Api::V1::ImportsControllerTest < ActionDispatch::IntegrationTest assert_no_difference("Import.count") do post api_v1_imports_url, params: { raw_file_content: large_content }, - headers: { Authorization: "Bearer #{@token}" } + headers: api_headers(@api_key) end assert_response :unprocessable_entity @@ -191,7 +248,7 @@ class Api::V1::ImportsControllerTest < ActionDispatch::IntegrationTest name_col_label: "name", account_id: @account.id }, - headers: { Authorization: "Bearer #{@token}" } + headers: api_headers(@api_key) end assert_response :created @@ -199,8 +256,7 @@ class Api::V1::ImportsControllerTest < ActionDispatch::IntegrationTest private - def valid_token_for(user) - application = Doorkeeper::Application.create!(name: "Test App", redirect_uri: "urn:ietf:wg:oauth:2.0:oob", scopes: "read read_write") - Doorkeeper::AccessToken.create!(application: application, resource_owner_id: user.id, scopes: "read read_write").token + def api_headers(api_key) + { "X-Api-Key" => api_key.display_key } end end diff --git a/test/controllers/api/v1/users_controller_test.rb b/test/controllers/api/v1/users_controller_test.rb index 9ea8b89cb..9a8be9d85 100644 --- a/test/controllers/api/v1/users_controller_test.rb +++ b/test/controllers/api/v1/users_controller_test.rb @@ -50,6 +50,24 @@ class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest # -- Reset ----------------------------------------------------------------- + + test "reset requires admin role" do + non_admin_api_key = ApiKey.create!( + user: users(:family_member), + name: "Member Read-Write Key", + scopes: [ "read_write" ], + display_key: "test_member_#{SecureRandom.hex(8)}" + ) + + assert_no_enqueued_jobs only: FamilyResetJob do + delete "/api/v1/users/reset", headers: api_headers(non_admin_api_key) + end + + assert_response :forbidden + body = JSON.parse(response.body) + assert_equal "You are not authorized to perform this action", body["message"] + end + test "reset enqueues FamilyResetJob and returns 200" do assert_enqueued_with(job: FamilyResetJob) do delete "/api/v1/users/reset", headers: api_headers(@api_key) diff --git a/test/controllers/archived_exports_controller_test.rb b/test/controllers/archived_exports_controller_test.rb new file mode 100644 index 000000000..0e23cb286 --- /dev/null +++ b/test/controllers/archived_exports_controller_test.rb @@ -0,0 +1,57 @@ +require "test_helper" + +class ArchivedExportsControllerTest < ActionDispatch::IntegrationTest + test "redirects to file with valid token" do + archive = ArchivedExport.create!( + email: "test@example.com", + family_name: "Test", + expires_at: 30.days.from_now + ) + archive.export_file.attach( + io: StringIO.new("test zip content"), + filename: "test.zip", + content_type: "application/zip" + ) + + get archived_export_path(token: archive.download_token) + assert_response :redirect + end + + test "returns 410 gone for expired token" do + archive = ArchivedExport.create!( + email: "test@example.com", + family_name: "Test", + expires_at: 1.day.ago + ) + archive.export_file.attach( + io: StringIO.new("test zip content"), + filename: "test.zip", + content_type: "application/zip" + ) + + get archived_export_path(token: archive.download_token) + assert_response :gone + end + + test "returns 404 for invalid token" do + get archived_export_path(token: "nonexistent-token") + assert_response :not_found + end + + test "does not require authentication" do + archive = ArchivedExport.create!( + email: "test@example.com", + family_name: "Test", + expires_at: 30.days.from_now + ) + archive.export_file.attach( + io: StringIO.new("test zip content"), + filename: "test.zip", + content_type: "application/zip" + ) + + # No sign_in call - should still work + get archived_export_path(token: archive.download_token) + assert_response :redirect + end +end diff --git a/test/controllers/binance_items_controller_test.rb b/test/controllers/binance_items_controller_test.rb new file mode 100644 index 000000000..91b13d60b --- /dev/null +++ b/test/controllers/binance_items_controller_test.rb @@ -0,0 +1,184 @@ +require "test_helper" + +class BinanceItemsControllerTest < ActionDispatch::IntegrationTest + include ActiveJob::TestHelper + + setup do + sign_in users(:family_admin) + @family = families(:dylan_family) + @binance_item = BinanceItem.create!( + family: @family, + name: "Test Binance", + api_key: "test_key", + api_secret: "test_secret" + ) + end + + test "should destroy binance item" do + assert_difference("BinanceItem.count", 0) do # doesn't delete immediately + delete binance_item_url(@binance_item) + end + + assert_redirected_to settings_providers_path + @binance_item.reload + assert @binance_item.scheduled_for_deletion? + end + + test "should sync binance item" do + post sync_binance_item_url(@binance_item) + assert_response :redirect + end + + test "should show setup_accounts page" do + get setup_accounts_binance_item_url(@binance_item) + assert_response :success + end + + test "complete_account_setup creates accounts for selected binance_accounts" do + binance_account = @binance_item.binance_accounts.create!( + name: "Spot Portfolio", + account_type: "spot", + currency: "USD", + current_balance: 1000.0 + ) + + assert_difference "Account.count", 1 do + post complete_account_setup_binance_item_url(@binance_item), params: { + selected_accounts: [ binance_account.id ] + } + end + + assert_response :redirect + + binance_account.reload + assert_not_nil binance_account.current_account + assert_equal "Crypto", binance_account.current_account.accountable_type + end + + test "complete_account_setup with no selection shows message" do + @binance_item.binance_accounts.create!( + name: "Spot Portfolio", + account_type: "spot", + currency: "USD", + current_balance: 1000.0 + ) + + assert_no_difference "Account.count" do + post complete_account_setup_binance_item_url(@binance_item), params: { + selected_accounts: [] + } + end + + assert_response :redirect + end + + test "complete_account_setup skips already linked accounts" do + binance_account = @binance_item.binance_accounts.create!( + name: "Spot Portfolio", + account_type: "spot", + currency: "USD", + current_balance: 1000.0 + ) + + # Pre-link the account + account = Account.create!( + family: @family, + name: "Existing Binance", + balance: 1000, + currency: "USD", + accountable: Crypto.create!(subtype: "exchange") + ) + AccountProvider.create!(account: account, provider: binance_account) + + assert_no_difference "Account.count" do + post complete_account_setup_binance_item_url(@binance_item), params: { + selected_accounts: [ binance_account.id ] + } + end + end + + test "cannot access other family's binance_item" do + other_family = families(:empty) + other_item = BinanceItem.create!( + family: other_family, + name: "Other Binance", + api_key: "other_test_key", + api_secret: "other_test_secret" + ) + + get setup_accounts_binance_item_url(other_item) + assert_response :not_found + end + + test "link_existing_account links manual account to binance_account" do + manual_account = Account.create!( + family: @family, + name: "Manual Crypto", + balance: 0, + currency: "USD", + accountable: Crypto.create!(subtype: "exchange") + ) + + binance_account = @binance_item.binance_accounts.create!( + name: "Spot Portfolio", + account_type: "spot", + currency: "USD", + current_balance: 1000.0 + ) + + assert_difference "AccountProvider.count", 1 do + post link_existing_account_binance_items_url, params: { + account_id: manual_account.id, + binance_account_id: binance_account.id + } + end + + binance_account.reload + assert_equal manual_account, binance_account.current_account + end + + test "link_existing_account rejects account with existing provider" do + linked_account = Account.create!( + family: @family, + name: "Already Linked", + balance: 0, + currency: "USD", + accountable: Crypto.create!(subtype: "exchange") + ) + + other_binance_account = @binance_item.binance_accounts.create!( + name: "Other Account", + account_type: "margin", + currency: "USD", + current_balance: 500.0 + ) + AccountProvider.create!(account: linked_account, provider: other_binance_account) + + binance_account = @binance_item.binance_accounts.create!( + name: "Spot Portfolio", + account_type: "spot", + currency: "USD", + current_balance: 1000.0 + ) + + assert_no_difference "AccountProvider.count" do + post link_existing_account_binance_items_url, params: { + account_id: linked_account.id, + binance_account_id: binance_account.id + } + end + end + + test "select_existing_account renders without layout" do + account = Account.create!( + family: @family, + name: "Manual Account", + balance: 0, + currency: "USD", + accountable: Crypto.create!(subtype: "exchange") + ) + + get select_existing_account_binance_items_url, params: { account_id: account.id } + assert_response :success + end +end diff --git a/test/controllers/budget_categories_controller_test.rb b/test/controllers/budget_categories_controller_test.rb new file mode 100644 index 000000000..96c6348a5 --- /dev/null +++ b/test/controllers/budget_categories_controller_test.rb @@ -0,0 +1,97 @@ +require "test_helper" + +class BudgetCategoriesControllerTest < ActionDispatch::IntegrationTest + include ActionView::RecordIdentifier + + setup do + sign_in users(:family_admin) + + @budget = budgets(:one) + @family = @budget.family + + @parent_category = Category.create!( + name: "Bills controller test", + family: @family, + color: "#4da568", + lucide_icon: "house" + ) + + @electric_category = Category.create!( + name: "Electric controller test", + parent: @parent_category, + family: @family + ) + + @water_category = Category.create!( + name: "Water controller test", + parent: @parent_category, + family: @family + ) + + @parent_budget_category = BudgetCategory.create!( + budget: @budget, + category: @parent_category, + budgeted_spending: 500, + currency: "USD" + ) + + @electric_budget_category = BudgetCategory.create!( + budget: @budget, + category: @electric_category, + budgeted_spending: 100, + currency: "USD" + ) + + @water_budget_category = BudgetCategory.create!( + budget: @budget, + category: @water_category, + budgeted_spending: 50, + currency: "USD" + ) + end + + test "updating a subcategory adjusts the parent budget by the same delta" do + assert_changes -> { @parent_budget_category.reload.budgeted_spending.to_f }, from: 500.0, to: 550.0 do + patch budget_budget_category_path(@budget, @electric_budget_category), + params: { budget_category: { budgeted_spending: 150 } }, + as: :turbo_stream + end + + assert_response :success + assert_includes @response.body, dom_id(@parent_budget_category, :form) + end + + test "manual parent budget remains on top of subcategory changes" do + @parent_budget_category.update!(budgeted_spending: 900) + + assert_changes -> { @parent_budget_category.reload.budgeted_spending.to_f }, from: 900.0, to: 975.0 do + patch budget_budget_category_path(@budget, @water_budget_category), + params: { budget_category: { budgeted_spending: 125 } }, + as: :turbo_stream + end + end + + test "sibling subcategory budget form rerenders without a max allocation cap" do + patch budget_budget_category_path(@budget, @electric_budget_category), + params: { budget_category: { budgeted_spending: 125 } }, + as: :turbo_stream + + assert_response :success + + fragment = Nokogiri::HTML.fragment(@response.body) + input = fragment.at_css("input##{dom_id(@water_budget_category, :budgeted_spending)}") + + assert_not_nil input + assert_nil input["max"] + end + + test "clearing a subcategory budget switches it back to shared and lowers the parent" do + assert_changes -> { @parent_budget_category.reload.budgeted_spending.to_f }, from: 500.0, to: 400.0 do + patch budget_budget_category_path(@budget, @electric_budget_category), + params: { budget_category: { budgeted_spending: "" } }, + as: :turbo_stream + end + + assert_equal 0.0, @electric_budget_category.reload.budgeted_spending.to_f + end +end diff --git a/test/controllers/coinstats_items_controller_test.rb b/test/controllers/coinstats_items_controller_test.rb index 9b29ec7a9..a3bfc24e0 100644 --- a/test/controllers/coinstats_items_controller_test.rb +++ b/test/controllers/coinstats_items_controller_test.rb @@ -9,6 +9,9 @@ class CoinstatsItemsControllerTest < ActionDispatch::IntegrationTest name: "Test CoinStats Connection", api_key: "test_api_key_123" ) + tailwind_build = Rails.root.join("app/assets/builds/tailwind.css") + FileUtils.mkdir_p(tailwind_build.dirname) + File.write(tailwind_build, "/* test */") unless tailwind_build.exist? end # Helper to wrap data in Provider::Response @@ -175,4 +178,38 @@ class CoinstatsItemsControllerTest < ActionDispatch::IntegrationTest assert_response :unprocessable_entity assert_match(/No tokens found/, response.body) end + + test "link_exchange filters unexpected connection fields" do + Provider::Coinstats.any_instance.expects(:get_exchanges).returns(success_response([ + { + connectionId: "bitvavo", + name: "Bitvavo", + connectionFields: [ + { key: "apiKey", name: "API Key" }, + { key: "apiSecret", name: "API Secret" } + ] + } + ])).once + + linker_result = CoinstatsItem::ExchangeLinker::Result.new(success?: true, created_count: 0, errors: []) + CoinstatsItem::ExchangeLinker.expects(:new).with( + @coinstats_item, + connection_id: "bitvavo", + connection_fields: { "apiKey" => "key", "apiSecret" => "secret" }, + name: "Bitvavo" + ).returns(stub(link: linker_result)) + + post link_exchange_coinstats_items_url, params: { + coinstats_item_id: @coinstats_item.id, + exchange_connection_id: "bitvavo", + exchange_connection_name: "Bitvavo", + connection_fields: { + apiKey: " key ", + apiSecret: " secret ", + unexpected: "should_not_be_forwarded" + } + } + + assert_redirected_to accounts_path + end end diff --git a/test/controllers/family_exports_controller_test.rb b/test/controllers/family_exports_controller_test.rb index a7a820ae3..217a6c170 100644 --- a/test/controllers/family_exports_controller_test.rb +++ b/test/controllers/family_exports_controller_test.rb @@ -140,6 +140,17 @@ class FamilyExportsControllerTest < ActionDispatch::IntegrationTest assert_not ActiveStorage::Attachment.exists?(file_id) end + test "index responds to html with settings layout" do + get family_exports_path + assert_response :success + assert_select "title" # rendered with layout + end + + test "index responds to turbo_stream without raising MissingTemplate" do + get family_exports_path, headers: { "Accept" => "text/vnd.turbo-stream.html" } + assert_redirected_to family_exports_path + end + test "non-admin cannot delete export" do export = @family.family_exports.create!(status: "completed") sign_in @non_admin diff --git a/test/controllers/family_merchants_controller_test.rb b/test/controllers/family_merchants_controller_test.rb index eda482a1b..b5dc950b3 100644 --- a/test/controllers/family_merchants_controller_test.rb +++ b/test/controllers/family_merchants_controller_test.rb @@ -36,4 +36,12 @@ class FamilyMerchantsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to family_merchants_path end + + test "enhance enqueues job and redirects" do + assert_enqueued_with(job: EnhanceProviderMerchantsJob) do + post enhance_family_merchants_path + end + + assert_redirected_to family_merchants_path + end end diff --git a/test/controllers/holdings_controller_test.rb b/test/controllers/holdings_controller_test.rb index a73680d54..dfa52d499 100644 --- a/test/controllers/holdings_controller_test.rb +++ b/test/controllers/holdings_controller_test.rb @@ -61,4 +61,61 @@ class HoldingsControllerTest < ActionDispatch::IntegrationTest assert_equal 50.0, @holding.cost_basis.to_f assert_equal "manual", @holding.cost_basis_source end + + test "remap_security brings offline security back online" do + # Given: the target security is marked offline (e.g. created by a failed QIF import) + msft = securities(:msft) + msft.update!(offline: true, failed_fetch_count: 3) + + # When: user explicitly selects it from the provider search and saves + patch remap_security_holding_path(@holding), params: { security_id: "MSFT|XNAS" } + + # Then: the security is brought back online and the holding is remapped + assert_redirected_to account_path(@holding.account, tab: "holdings") + @holding.reload + msft.reload + assert_equal msft.id, @holding.security_id + assert_not msft.offline? + assert_equal 0, msft.failed_fetch_count + end + + test "sync_prices redirects with alert for offline security" do + @holding.security.update!(offline: true) + + post sync_prices_holding_path(@holding) + + assert_redirected_to account_path(@holding.account, tab: "holdings") + assert_equal I18n.t("holdings.sync_prices.unavailable"), flash[:alert] + end + + test "sync_prices syncs market data and redirects with notice" do + Security.any_instance.expects(:import_provider_prices).with( + start_date: 31.days.ago.to_date, + end_date: Date.current, + clear_cache: true + ).returns([ 31, nil ]) + Security.any_instance.stubs(:import_provider_details) + materializer = mock("materializer") + materializer.expects(:materialize_balances).once + Balance::Materializer.expects(:new).with( + @holding.account, + strategy: :forward, + security_ids: [ @holding.security_id ] + ).returns(materializer) + + post sync_prices_holding_path(@holding) + + assert_redirected_to account_path(@holding.account, tab: "holdings") + assert_equal I18n.t("holdings.sync_prices.success"), flash[:notice] + end + + test "sync_prices shows provider error inline when provider returns no prices" do + Security.any_instance.stubs(:import_provider_prices).returns([ 0, "Yahoo Finance rate limit exceeded" ]) + Security.any_instance.stubs(:import_provider_details) + + post sync_prices_holding_path(@holding) + + assert_redirected_to account_path(@holding.account, tab: "holdings") + assert_equal "Yahoo Finance rate limit exceeded", flash[:alert] + end end diff --git a/test/controllers/impersonation_sessions_controller_test.rb b/test/controllers/impersonation_sessions_controller_test.rb index 7adda7b74..7dce7f5ac 100644 --- a/test/controllers/impersonation_sessions_controller_test.rb +++ b/test/controllers/impersonation_sessions_controller_test.rb @@ -11,7 +11,7 @@ class ImpersonationSessionsControllerTest < ActionDispatch::IntegrationTest assert_difference "impersonator_session.logs.count", 2 do get root_path - get account_path(impersonated.family.accounts.first) + get account_path(impersonated.accessible_accounts.first) end end diff --git a/test/controllers/imports_controller_test.rb b/test/controllers/imports_controller_test.rb index 72f04c1cf..dbea78d2d 100644 --- a/test/controllers/imports_controller_test.rb +++ b/test/controllers/imports_controller_test.rb @@ -32,8 +32,9 @@ class ImportsControllerTest < ActionDispatch::IntegrationTest assert_select "button", text: "Import accounts" assert_select "button", text: "Import transactions", count: 0 assert_select "button", text: "Import investments", count: 0 - assert_select "button", text: "Import from Mint", count: 0 - assert_select "span", text: "Import accounts first to unlock this option.", count: 3 + assert_select "button", text: "Import from Mint", count: 1 + assert_select "button", text: "Import from Quicken (QIF)", count: 1 + assert_select "span", text: "Import accounts first to unlock this option.", count: 2 assert_select "div[aria-disabled=true]", count: 3 end diff --git a/test/controllers/invite_codes_controller_test.rb b/test/controllers/invite_codes_controller_test.rb index ea39395fb..2403a3732 100644 --- a/test/controllers/invite_codes_controller_test.rb +++ b/test/controllers/invite_codes_controller_test.rb @@ -4,17 +4,21 @@ class InviteCodesControllerTest < ActionDispatch::IntegrationTest setup do Rails.application.config.app_mode.stubs(:self_hosted?).returns(true) end - test "admin can generate invite codes" do - sign_in users(:family_admin) + test "super admin can generate invite codes" do + sign_in users(:sure_support_staff) assert_difference("InviteCode.count") do post invite_codes_url, params: {} end end - test "non-admin cannot generate invite codes" do - sign_in users(:family_member) + test "non-super-admin cannot generate invite codes" do + sign_in users(:family_admin) - assert_raises(StandardError) { post invite_codes_url, params: {} } + assert_no_difference("InviteCode.count") do + post invite_codes_url, params: {} + end + + assert_redirected_to root_path end end diff --git a/test/controllers/pages_controller_test.rb b/test/controllers/pages_controller_test.rb index 2c636ffd9..73c39f5a1 100644 --- a/test/controllers/pages_controller_test.rb +++ b/test/controllers/pages_controller_test.rb @@ -31,8 +31,8 @@ class PagesControllerTest < ActionDispatch::IntegrationTest test "dashboard renders sankey chart with subcategories" do # Create parent category with subcategory - parent_category = @family.categories.create!(name: "Shopping", classification: "expense", color: "#FF5733") - subcategory = @family.categories.create!(name: "Groceries", classification: "expense", parent: parent_category, color: "#33FF57") + parent_category = @family.categories.create!(name: "Shopping", color: "#FF5733") + subcategory = @family.categories.create!(name: "Groceries", parent: parent_category, color: "#33FF57") # Create transactions using helper create_transaction(account: @family.accounts.first, name: "General shopping", amount: 100, category: parent_category) diff --git a/test/controllers/pending_duplicate_merges_controller_test.rb b/test/controllers/pending_duplicate_merges_controller_test.rb new file mode 100644 index 000000000..91ed723a4 --- /dev/null +++ b/test/controllers/pending_duplicate_merges_controller_test.rb @@ -0,0 +1,205 @@ +require "test_helper" + +class PendingDuplicateMergesControllerTest < ActionDispatch::IntegrationTest + include EntriesTestHelper + + setup do + sign_in @user = users(:family_admin) + @account = accounts(:depository) + end + + test "new displays pending transaction and candidate posted transactions" do + pending_transaction = create_pending_transaction(amount: -50, account: @account) + posted_transaction = create_transaction(amount: -50, account: @account) + + get new_transaction_pending_duplicate_merges_path(pending_transaction) + + assert_response :success + end + + test "new redirects if transaction is not pending" do + posted_transaction = create_transaction(amount: -50, account: @account) + + get new_transaction_pending_duplicate_merges_path(posted_transaction) + + assert_redirected_to transactions_path + assert_equal "This feature is only available for pending transactions", flash[:alert] + end + + test "create merges pending transaction with selected posted transaction" do + pending_transaction = create_pending_transaction(amount: -50, account: @account) + posted_transaction = create_transaction(amount: -50, account: @account) + + assert_difference "Entry.count", -1 do + post transaction_pending_duplicate_merges_path(pending_transaction), params: { + pending_duplicate_merges: { + posted_entry_id: posted_transaction.id + } + } + end + + assert_redirected_to transactions_path + assert_equal "Pending transaction merged with posted transaction", flash[:notice] + assert_nil Entry.find_by(id: pending_transaction.id), "Pending entry should be deleted after merge" + end + + test "create redirects back to referer after successful merge" do + pending_transaction = create_pending_transaction(amount: -50, account: @account) + posted_transaction = create_transaction(amount: -50, account: @account) + + assert_difference "Entry.count", -1 do + post transaction_pending_duplicate_merges_path(pending_transaction), + params: { + pending_duplicate_merges: { + posted_entry_id: posted_transaction.id + } + }, + headers: { "HTTP_REFERER" => account_path(@account) } + end + + assert_redirected_to account_path(@account) + assert_equal "Pending transaction merged with posted transaction", flash[:notice] + end + + test "create redirects with error if no posted entry selected" do + pending_transaction = create_pending_transaction(amount: -50, account: @account) + + post transaction_pending_duplicate_merges_path(pending_transaction), params: { + pending_duplicate_merges: { + posted_entry_id: "" + } + } + + assert_redirected_to transactions_path + assert_equal "Please select a posted transaction to merge with", flash[:alert] + end + + test "create stores potential_posted_match metadata before merging" do + pending_transaction = create_pending_transaction(amount: -50, account: @account) + posted_transaction = create_transaction(amount: -50, account: @account) + + # Stub merge to prevent deletion so we can check metadata + Transaction.any_instance.stubs(:merge_with_duplicate!).returns(true) + + post transaction_pending_duplicate_merges_path(pending_transaction), params: { + pending_duplicate_merges: { + posted_entry_id: posted_transaction.id + } + } + + pending_transaction.reload + metadata = pending_transaction.entryable.extra["potential_posted_match"] + + assert_not_nil metadata + assert_equal posted_transaction.id, metadata["entry_id"] + assert_equal "manual_match", metadata["reason"] + assert_equal posted_transaction.amount.to_s, metadata["posted_amount"] + assert_equal "high", metadata["confidence"] + assert_equal Date.current.to_s, metadata["detected_at"] + end + + test "pending_duplicate_candidates excludes pending transactions" do + pending_transaction = create_pending_transaction(amount: -50, account: @account) + posted_transaction = create_transaction(amount: -50, account: @account) + another_pending = create_pending_transaction(amount: -40, account: @account) + + candidates = pending_transaction.entryable.pending_duplicate_candidates + + assert_includes candidates.map(&:id), posted_transaction.id + assert_not_includes candidates.map(&:id), another_pending.id + end + + test "pending_duplicate_candidates only shows same account and currency" do + pending_transaction = create_pending_transaction(amount: -50, account: @account, currency: "USD") + same_account_transaction = create_transaction(amount: -50, account: @account, currency: "USD") + different_account_transaction = create_transaction(amount: -50, account: accounts(:investment), currency: "USD") + different_currency_transaction = create_transaction(amount: -50, account: @account, currency: "EUR") + + candidates = pending_transaction.entryable.pending_duplicate_candidates + + assert_includes candidates.map(&:id), same_account_transaction.id + assert_not_includes candidates.map(&:id), different_account_transaction.id + assert_not_includes candidates.map(&:id), different_currency_transaction.id + end + + test "create rejects merge with pending transaction" do + pending_transaction = create_pending_transaction(amount: -50, account: @account) + another_pending = create_pending_transaction(amount: -50, account: @account) + + assert_no_difference "Entry.count" do + post transaction_pending_duplicate_merges_path(pending_transaction), params: { + pending_duplicate_merges: { + posted_entry_id: another_pending.id + } + } + end + + assert_redirected_to transactions_path + assert_equal "Invalid transaction selected for merge", flash[:alert] + end + + test "create rejects merge with transaction from different account" do + pending_transaction = create_pending_transaction(amount: -50, account: @account) + different_account_transaction = create_transaction(amount: -50, account: accounts(:investment)) + + assert_no_difference "Entry.count" do + post transaction_pending_duplicate_merges_path(pending_transaction), params: { + pending_duplicate_merges: { + posted_entry_id: different_account_transaction.id + } + } + end + + assert_redirected_to transactions_path + assert_equal "Invalid transaction selected for merge", flash[:alert] + end + + test "create rejects merge with transaction in different currency" do + pending_transaction = create_pending_transaction(amount: -50, account: @account, currency: "USD") + different_currency_transaction = create_transaction(amount: -50, account: @account, currency: "EUR") + + assert_no_difference "Entry.count" do + post transaction_pending_duplicate_merges_path(pending_transaction), params: { + pending_duplicate_merges: { + posted_entry_id: different_currency_transaction.id + } + } + end + + assert_redirected_to transactions_path + assert_equal "Invalid transaction selected for merge", flash[:alert] + end + + test "create rejects merge with invalid entry id" do + pending_transaction = create_pending_transaction(amount: -50, account: @account) + + assert_no_difference "Entry.count" do + post transaction_pending_duplicate_merges_path(pending_transaction), params: { + pending_duplicate_merges: { + posted_entry_id: 999999 + } + } + end + + assert_redirected_to transactions_path + assert_equal "Invalid transaction selected for merge", flash[:alert] + end + + private + + def create_pending_transaction(attributes = {}) + # Create a transaction with pending metadata + transaction = create_transaction(attributes) + + # Mark it as pending by adding extra metadata + transaction.entryable.update!( + extra: { + "simplefin" => { + "pending" => true + } + } + ) + + transaction + end +end diff --git a/test/controllers/reports_controller_test.rb b/test/controllers/reports_controller_test.rb index 1c7e7bbd2..c53472e99 100644 --- a/test/controllers/reports_controller_test.rb +++ b/test/controllers/reports_controller_test.rb @@ -82,8 +82,8 @@ class ReportsControllerTest < ActionDispatch::IntegrationTest get reports_path(period_type: :monthly) assert_response :ok assert_select "h2", text: I18n.t("reports.trends.title") - assert_select '[role="columnheader"]' do - assert_select "div", text: I18n.t("reports.trends.month") + assert_select "thead" do + assert_select "th", text: I18n.t("reports.trends.month") end end @@ -118,8 +118,7 @@ class ReportsControllerTest < ActionDispatch::IntegrationTest test "spending patterns returns data when expense transactions exist" do # Create expense category expense_category = @family.categories.create!( - name: "Test Groceries", - classification: "expense" + name: "Test Groceries" ) # Create account @@ -228,9 +227,9 @@ class ReportsControllerTest < ActionDispatch::IntegrationTest test "index groups transactions by parent and subcategories" do # Create parent category with subcategories - parent_category = @family.categories.create!(name: "Entertainment", classification: "expense", color: "#FF5733") - subcategory_movies = @family.categories.create!(name: "Movies", classification: "expense", parent: parent_category, color: "#33FF57") - subcategory_games = @family.categories.create!(name: "Games", classification: "expense", parent: parent_category, color: "#5733FF") + parent_category = @family.categories.create!(name: "Entertainment", color: "#FF5733") + subcategory_movies = @family.categories.create!(name: "Movies", parent: parent_category, color: "#33FF57") + subcategory_games = @family.categories.create!(name: "Games", parent: parent_category, color: "#5733FF") # Create transactions using helper create_transaction(account: @family.accounts.first, name: "Cinema ticket", amount: 15, category: subcategory_movies) @@ -240,10 +239,10 @@ class ReportsControllerTest < ActionDispatch::IntegrationTest assert_response :ok # Parent category - assert_select "div[data-category='category-#{parent_category.id}']", text: /^Entertainment/ + assert_select "tr[data-category='category-#{parent_category.id}']", text: /^Entertainment/ # Subcategories - assert_select "div[data-category='category-#{subcategory_movies.id}']", text: /^Movies/ - assert_select "div[data-category='category-#{subcategory_games.id}']", text: /^Games/ + assert_select "tr[data-category='category-#{subcategory_movies.id}']", text: /^Movies/ + assert_select "tr[data-category='category-#{subcategory_games.id}']", text: /^Games/ end end diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index 8bdad7bf0..d3ec232ef 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -543,20 +543,39 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest { name: "openid_connect", strategy: "openid_connect", label: "Google" } ]) - get "/auth/mobile/openid_connect", params: { - device_id: "flutter-device-006", - device_name: "Pixel 8", - device_type: "android" - } - get "/auth/openid_connect/callback" + # Use a real cache store so we can verify the cache entry written by handle_mobile_sso_onboarding + original_cache = Rails.cache + Rails.cache = ActiveSupport::Cache::MemoryStore.new - assert_response :redirect - redirect_url = @response.redirect_url + begin + get "/auth/mobile/openid_connect", params: { + device_id: "flutter-device-006", + device_name: "Pixel 8", + device_type: "android" + } + get "/auth/openid_connect/callback" - assert redirect_url.start_with?("sureapp://oauth/callback?"), "Expected redirect to sureapp://" - params = Rack::Utils.parse_query(URI.parse(redirect_url).query) - assert_equal "account_not_linked", params["error"] - assert_nil session[:mobile_sso], "Expected mobile_sso session to be cleared" + assert_response :redirect + redirect_url = @response.redirect_url + + assert redirect_url.start_with?("sureapp://oauth/callback?"), "Expected redirect to sureapp://" + params = Rack::Utils.parse_query(URI.parse(redirect_url).query) + assert_equal "account_not_linked", params["status"] + assert params["linking_code"].present?, "Expected linking_code in redirect params" + assert_nil session[:mobile_sso], "Expected mobile_sso session to be cleared" + + # Verify the cache entry written by handle_mobile_sso_onboarding + cached = Rails.cache.read("mobile_sso_link:#{params['linking_code']}") + assert cached.present?, "Expected cache entry for mobile_sso_link:#{params['linking_code']}" + assert_equal "openid_connect", cached[:provider] + assert_equal "unlinked-uid-99999", cached[:uid] + assert_equal user_without_oidc.email, cached[:email] + assert_equal "New User", cached[:name] + assert cached.key?(:device_info), "Expected device_info in cached payload" + assert cached.key?(:allow_account_creation), "Expected allow_account_creation in cached payload" + ensure + Rails.cache = original_cache + end end test "mobile SSO does not create a web session" do diff --git a/test/controllers/settings/hostings_controller_test.rb b/test/controllers/settings/hostings_controller_test.rb index 5b1edb7cf..f4706c07c 100644 --- a/test/controllers/settings/hostings_controller_test.rb +++ b/test/controllers/settings/hostings_controller_test.rb @@ -51,6 +51,8 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest end test "can update onboarding state when self hosting is enabled" do + sign_in users(:sure_support_staff) + with_self_hosting do patch settings_hosting_url, params: { setting: { onboarding_state: "invite_only" } } @@ -136,6 +138,103 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest assert_not Balance.exists?(account_balance.id) end + test "can update assistant type to external" do + with_self_hosting do + assert_equal "builtin", users(:family_admin).family.assistant_type + + patch settings_hosting_url, params: { family: { assistant_type: "external" } } + + assert_redirected_to settings_hosting_url + assert_equal "external", users(:family_admin).family.reload.assistant_type + end + end + + test "ignores invalid assistant type values" do + with_self_hosting do + patch settings_hosting_url, params: { family: { assistant_type: "hacked" } } + + assert_redirected_to settings_hosting_url + assert_equal "builtin", users(:family_admin).family.reload.assistant_type + end + end + + test "ignores assistant type update when ASSISTANT_TYPE env is set" do + with_self_hosting do + with_env_overrides("ASSISTANT_TYPE" => "external") do + patch settings_hosting_url, params: { family: { assistant_type: "external" } } + + assert_redirected_to settings_hosting_url + # DB value should NOT change when env override is active + assert_equal "builtin", users(:family_admin).family.reload.assistant_type + end + end + end + + test "can update external assistant settings" do + with_self_hosting do + patch settings_hosting_url, params: { setting: { + external_assistant_url: "https://agent.example.com/v1/chat", + external_assistant_token: "my-secret-token", + external_assistant_agent_id: "finance-bot" + } } + + assert_redirected_to settings_hosting_url + assert_equal "https://agent.example.com/v1/chat", Setting.external_assistant_url + assert_equal "my-secret-token", Setting.external_assistant_token + assert_equal "finance-bot", Setting.external_assistant_agent_id + end + ensure + Setting.external_assistant_url = nil + Setting.external_assistant_token = nil + Setting.external_assistant_agent_id = nil + end + + test "does not overwrite token with masked placeholder" do + with_self_hosting do + Setting.external_assistant_token = "real-secret" + + patch settings_hosting_url, params: { setting: { external_assistant_token: "********" } } + + assert_equal "real-secret", Setting.external_assistant_token + end + ensure + Setting.external_assistant_token = nil + end + + test "disconnect external assistant clears settings and resets type" do + with_self_hosting do + with_env_overrides("EXTERNAL_ASSISTANT_URL" => nil, "EXTERNAL_ASSISTANT_TOKEN" => nil) do + Setting.external_assistant_url = "https://agent.example.com/v1/chat" + Setting.external_assistant_token = "token" + Setting.external_assistant_agent_id = "finance-bot" + users(:family_admin).family.update!(assistant_type: "external") + + delete disconnect_external_assistant_settings_hosting_url + + assert_redirected_to settings_hosting_url + # Force cache refresh so configured? reads fresh DB state after + # the disconnect action cleared the settings within its own request. + Setting.clear_cache + assert_not Assistant::External.configured? + assert_equal "builtin", users(:family_admin).family.reload.assistant_type + end + end + ensure + Setting.external_assistant_url = nil + Setting.external_assistant_token = nil + Setting.external_assistant_agent_id = nil + end + + test "disconnect external assistant requires admin" do + with_self_hosting do + sign_in users(:family_member) + delete disconnect_external_assistant_settings_hosting_url + + assert_redirected_to settings_hosting_url + assert_equal I18n.t("settings.hostings.not_authorized"), flash[:alert] + end + end + test "can clear data only when admin" do with_self_hosting do sign_in users(:family_member) diff --git a/test/controllers/snaptrade_items_controller_test.rb b/test/controllers/snaptrade_items_controller_test.rb index 2011832cb..18048ac2d 100644 --- a/test/controllers/snaptrade_items_controller_test.rb +++ b/test/controllers/snaptrade_items_controller_test.rb @@ -103,4 +103,66 @@ class SnaptradeItemsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to settings_providers_path assert_match(/not found/i, flash[:alert]) end + + # --- setup_accounts throttle-sync fix --- + # + # The fix on setup_accounts ensures sync_later is only called when there are no + # accounts AND the item has never been synced (last_synced_at.blank?). This + # prevents the infinite-spinner loop where every page load re-triggered a sync + # even after SnapTrade already confirmed 0 linked accounts. + # + # Three view-state branches we need to cover: + # A) No accounts + never synced → trigger sync, render spinner + # B) No accounts + synced once, now idle → skip sync, show "no accounts found" + # C) No accounts + synced once, still syncing → show spinner, do NOT re-queue + + test "setup_accounts triggers sync and shows spinner when item has no accounts and has never been synced" do + # Pre-condition: no snaptrade_accounts and no completed syncs (last_synced_at is nil) + @snaptrade_item.snaptrade_accounts.destroy_all + @snaptrade_item.syncs.destroy_all + + assert_difference "Sync.count", 1 do + get setup_accounts_snaptrade_item_url(@snaptrade_item) + end + + assert_response :success + assert_select "#snaptrade-sync-spinner", count: 1, message: "Expected the spinner to be shown on first visit with no accounts" + assert_select ".no-accounts-found", count: 0, message: "Expected the no-accounts UI to be hidden while syncing" + end + + test "setup_accounts shows no-accounts-found state after a completed sync returns zero accounts" do + # Pre-condition: no snaptrade_accounts, but there IS a past completed sync + @snaptrade_item.snaptrade_accounts.destroy_all + @snaptrade_item.syncs.destroy_all + @snaptrade_item.syncs.create!(status: :completed, completed_at: 1.minute.ago) + + # Item is not currently syncing → @syncing is false + assert_not @snaptrade_item.reload.syncing?, "Item should not be syncing for this test" + + assert_no_difference "Sync.count" do + get setup_accounts_snaptrade_item_url(@snaptrade_item) + end + + assert_response :success + assert_select ".no-accounts-found", count: 1, message: "Expected the no-accounts UI to be shown after a completed sync with zero accounts" + assert_select "#snaptrade-sync-spinner", count: 0, message: "Expected the spinner to be hidden when there is no active sync" + end + + test "setup_accounts does not re-queue a sync when a sync is already in progress" do + # Pre-condition: no accounts, one past completed sync, + one visible (in-flight) sync + @snaptrade_item.snaptrade_accounts.destroy_all + @snaptrade_item.syncs.destroy_all + @snaptrade_item.syncs.create!(status: :completed, completed_at: 5.minutes.ago) + @snaptrade_item.syncs.create!(status: :pending, created_at: 1.minute.ago) # visible/in-flight + + assert @snaptrade_item.reload.syncing?, "Item should be syncing for this test" + + assert_no_difference "Sync.count" do + get setup_accounts_snaptrade_item_url(@snaptrade_item) + end + + assert_response :success + assert_select "#snaptrade-sync-spinner", count: 1, message: "Expected the spinner to be shown while sync is in progress" + assert_select ".no-accounts-found", count: 0, message: "Expected the no-accounts UI to be hidden while a sync is active" + end end diff --git a/test/controllers/splits_controller_test.rb b/test/controllers/splits_controller_test.rb new file mode 100644 index 000000000..6a0cb3dad --- /dev/null +++ b/test/controllers/splits_controller_test.rb @@ -0,0 +1,212 @@ +require "test_helper" + +class SplitsControllerTest < ActionDispatch::IntegrationTest + include EntriesTestHelper + + setup do + sign_in @user = users(:family_admin) + @entry = create_transaction( + amount: 100, + name: "Grocery Store", + account: accounts(:depository) + ) + end + + test "new renders split editor" do + get new_transaction_split_path(@entry) + assert_response :success + end + + test "create with valid params splits transaction" do + assert_difference "Entry.count", 2 do + post transaction_split_path(@entry), params: { + split: { + splits: [ + { name: "Groceries", amount: "-70", category_id: categories(:food_and_drink).id }, + { name: "Household", amount: "-30", category_id: "" } + ] + } + } + end + + assert_redirected_to transactions_url + assert_equal I18n.t("splits.create.success"), flash[:notice] + assert @entry.reload.excluded? + assert @entry.split_parent? + end + + test "create with mismatched amounts rejects" do + assert_no_difference "Entry.count" do + post transaction_split_path(@entry), params: { + split: { + splits: [ + { name: "Part 1", amount: "-60", category_id: "" }, + { name: "Part 2", amount: "-20", category_id: "" } + ] + } + } + end + + assert_redirected_to transactions_url + assert flash[:alert].present? + end + + test "destroy unsplits transaction" do + @entry.split!([ + { name: "Part 1", amount: 50, category_id: nil }, + { name: "Part 2", amount: 50, category_id: nil } + ]) + + assert_difference "Entry.count", -2 do + delete transaction_split_path(@entry) + end + + assert_redirected_to transactions_url + assert_equal I18n.t("splits.destroy.success"), flash[:notice] + refute @entry.reload.excluded? + end + + test "create with income transaction applies correct sign" do + income_entry = create_transaction( + amount: -400, + name: "Reimbursement", + account: accounts(:depository) + ) + + assert_difference "Entry.count", 2 do + post transaction_split_path(income_entry), params: { + split: { + splits: [ + { name: "Part 1", amount: "200", category_id: "" }, + { name: "Part 2", amount: "200", category_id: "" } + ] + } + } + end + + assert income_entry.reload.excluded? + children = income_entry.child_entries + assert_equal(-200, children.first.amount.to_i) + assert_equal(-200, children.last.amount.to_i) + end + + test "create with mixed sign amounts on expense" do + assert_difference "Entry.count", 2 do + post transaction_split_path(@entry), params: { + split: { + splits: [ + { name: "Main expense", amount: "-130", category_id: "" }, + { name: "Refund", amount: "30", category_id: "" } + ] + } + } + end + + assert @entry.reload.excluded? + children = @entry.child_entries.order(:amount) + assert_equal(-30, children.first.amount.to_i) + assert_equal 130, children.last.amount.to_i + end + + test "only family members can access splits" do + other_family_entry = create_transaction( + amount: 100, + name: "Other", + account: accounts(:depository) + ) + + # This should work since both belong to same family + get new_transaction_split_path(other_family_entry) + assert_response :success + end + + # Edit action tests + test "edit renders with existing children pre-filled" do + @entry.split!([ + { name: "Part 1", amount: 60, category_id: nil }, + { name: "Part 2", amount: 40, category_id: nil } + ]) + + get edit_transaction_split_path(@entry) + assert_response :success + end + + test "edit on a child redirects to parent edit" do + @entry.split!([ + { name: "Part 1", amount: 60, category_id: nil }, + { name: "Part 2", amount: 40, category_id: nil } + ]) + child = @entry.child_entries.first + + get edit_transaction_split_path(child) + assert_response :success + end + + test "edit on a non-split entry redirects with alert" do + get edit_transaction_split_path(@entry) + assert_redirected_to transactions_url + assert_equal I18n.t("splits.edit.not_split"), flash[:alert] + end + + # Update action tests + test "update modifies split entries" do + @entry.split!([ + { name: "Part 1", amount: 60, category_id: nil }, + { name: "Part 2", amount: 40, category_id: nil } + ]) + + patch transaction_split_path(@entry), params: { + split: { + splits: [ + { name: "Food", amount: "-50", category_id: categories(:food_and_drink).id }, + { name: "Transport", amount: "-30", category_id: "" }, + { name: "Other", amount: "-20", category_id: "" } + ] + } + } + + assert_redirected_to transactions_url + assert_equal I18n.t("splits.update.success"), flash[:notice] + @entry.reload + assert @entry.split_parent? + assert_equal 3, @entry.child_entries.count + end + + test "update with mismatched amounts rejects" do + @entry.split!([ + { name: "Part 1", amount: 60, category_id: nil }, + { name: "Part 2", amount: 40, category_id: nil } + ]) + + patch transaction_split_path(@entry), params: { + split: { + splits: [ + { name: "Part 1", amount: "-70", category_id: "" }, + { name: "Part 2", amount: "-20", category_id: "" } + ] + } + } + + assert_redirected_to transactions_url + assert flash[:alert].present? + # Original splits should remain intact + assert_equal 2, @entry.reload.child_entries.count + end + + # Destroy from child tests + test "destroy from child resolves to parent and unsplits" do + @entry.split!([ + { name: "Part 1", amount: 60, category_id: nil }, + { name: "Part 2", amount: 40, category_id: nil } + ]) + child = @entry.child_entries.first + + assert_difference "Entry.count", -2 do + delete transaction_split_path(child) + end + + assert_redirected_to transactions_url + assert_equal I18n.t("splits.destroy.success"), flash[:notice] + refute @entry.reload.excluded? + end +end diff --git a/test/controllers/trades_controller_test.rb b/test/controllers/trades_controller_test.rb index 21e34249a..e93c63246 100644 --- a/test/controllers/trades_controller_test.rb +++ b/test/controllers/trades_controller_test.rb @@ -93,8 +93,8 @@ class TradesControllerTest < ActionDispatch::IntegrationTest assert_redirected_to @entry.account end - test "creates interest entry" do - assert_difference [ "Entry.count", "Transaction.count" ], 1 do + test "creates interest entry as trade with synthetic cash security when no ticker given" do + assert_difference [ "Entry.count", "Trade.count" ], 1 do post trades_url(account_id: @entry.account_id), params: { model: { type: "interest", @@ -108,9 +108,156 @@ class TradesControllerTest < ActionDispatch::IntegrationTest created_entry = Entry.order(created_at: :desc).first assert created_entry.amount.negative? + assert created_entry.trade? + assert created_entry.trade.security.cash? + assert_equal "Interest", created_entry.name assert_redirected_to @entry.account end + test "creates interest entry as trade with security when ticker given" do + assert_difference [ "Entry.count", "Trade.count" ], 1 do + post trades_url(account_id: @entry.account_id), params: { + model: { + type: "interest", + date: Date.current, + amount: 10, + currency: "USD", + ticker: "AAPL|XNAS" + } + } + end + + created_entry = Entry.order(created_at: :desc).first + + assert created_entry.amount.negative? + assert created_entry.trade? + assert_equal "AAPL", created_entry.trade.security.ticker + assert_equal "Interest: AAPL", created_entry.name + assert_redirected_to @entry.account + end + + test "creates dividend entry as trade with required security" do + assert_difference [ "Entry.count", "Trade.count" ], 1 do + post trades_url(account_id: @entry.account_id), params: { + model: { + type: "dividend", + date: Date.current, + amount: 25, + currency: "USD", + ticker: "AAPL|XNAS" + } + } + end + + created_entry = Entry.order(created_at: :desc).first + + assert created_entry.amount.negative? + assert created_entry.trade? + assert_equal 0, created_entry.trade.qty + assert_equal "AAPL", created_entry.trade.security.ticker + assert_equal "Dividend: AAPL", created_entry.name + assert_equal "Dividend", created_entry.trade.investment_activity_label + assert_redirected_to @entry.account + end + + test "creating dividend without security returns error" do + assert_no_difference [ "Entry.count", "Trade.count" ] do + post trades_url(account_id: @entry.account_id), params: { + model: { + type: "dividend", + date: Date.current, + amount: 25, + currency: "USD" + } + } + end + + assert_response :unprocessable_entity + end + + test "creates trade buy entry with fee" do + assert_difference [ "Entry.count", "Trade.count" ], 1 do + post trades_url(account_id: @entry.account_id), params: { + model: { + type: "buy", + date: Date.current, + ticker: "NVDA (NASDAQ)", + qty: 10, + price: 20, + fee: 9.95, + currency: "USD" + } + } + end + + created_entry = Entry.order(created_at: :desc).first + + assert_in_delta 209.95, created_entry.amount.to_f, 0.001 + assert_in_delta 9.95, created_entry.trade.fee.to_f, 0.001 + assert_redirected_to account_url(created_entry.account) + end + + test "creates trade sell entry with fee" do + assert_difference [ "Entry.count", "Trade.count" ], 1 do + post trades_url(account_id: @entry.account_id), params: { + model: { + type: "sell", + date: Date.current, + ticker: "AAPL (NYSE)", + qty: 10, + price: 20, + fee: 9.95, + currency: "USD" + } + } + end + + created_entry = Entry.order(created_at: :desc).first + + # sell: signed_amount = -10 * 20 + 9.95 = -190.05 + assert_in_delta(-190.05, created_entry.amount.to_f, 0.001) + assert_in_delta 9.95, created_entry.trade.fee.to_f, 0.001 + assert_redirected_to account_url(created_entry.account) + end + + test "creates trade buy entry without fee defaults to zero" do + post trades_url(account_id: @entry.account_id), params: { + model: { + type: "buy", + date: Date.current, + ticker: "NVDA (NASDAQ)", + qty: 10, + price: 20, + currency: "USD" + } + } + + created_entry = Entry.order(created_at: :desc).first + + assert_in_delta 200, created_entry.amount.to_f, 0.001 + assert_equal 0, created_entry.trade.fee.to_f + end + + test "update includes fee in amount" do + patch trade_url(@entry), params: { + entry: { + currency: "USD", + nature: "outflow", + entryable_attributes: { + id: @entry.entryable_id, + qty: 10, + price: 20, + fee: 9.95 + } + } + } + + @entry.reload + + assert_in_delta 209.95, @entry.amount.to_f, 0.001 + assert_in_delta 9.95, @entry.trade.fee.to_f, 0.001 + end + test "creates trade buy entry" do assert_difference [ "Entry.count", "Trade.count", "Security.count" ], 1 do post trades_url(account_id: @entry.account_id), params: { diff --git a/test/controllers/transaction_attachments_controller_test.rb b/test/controllers/transaction_attachments_controller_test.rb new file mode 100644 index 000000000..fdb6e22b9 --- /dev/null +++ b/test/controllers/transaction_attachments_controller_test.rb @@ -0,0 +1,144 @@ +require "test_helper" + +class TransactionAttachmentsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in @user = users(:family_admin) + @entry = entries(:transaction) + @transaction = @entry.entryable + end + + test "should upload attachment to transaction" do + file = fixture_file_upload("test.txt", "application/pdf") + + assert_difference "@transaction.attachments.count", 1 do + post transaction_attachments_path(@transaction), params: { attachment: file } + end + + assert_redirected_to transaction_path(@transaction) + assert_match "Attachment uploaded successfully", flash[:notice] + end + + test "should upload multiple attachments to transaction" do + file1 = fixture_file_upload("test.txt", "application/pdf") + file2 = fixture_file_upload("test.txt", "image/jpeg") + + assert_difference "@transaction.attachments.count", 2 do + post transaction_attachments_path(@transaction), params: { attachments: [ file1, file2 ] } + end + + assert_redirected_to transaction_path(@transaction) + assert_match "2 attachments uploaded successfully", flash[:notice] + end + + test "should ignore blank attachments in array" do + file = fixture_file_upload("test.txt", "application/pdf") + + assert_difference "@transaction.attachments.count", 1 do + # Simulate Rails behavior where an empty string is often sent in the array + post transaction_attachments_path(@transaction), params: { attachments: [ file, "" ] } + end + + assert_redirected_to transaction_path(@transaction) + assert_match "Attachment uploaded successfully", flash[:notice] # Should be singular + end + + test "should handle upload with no files" do + assert_no_difference "@transaction.attachments.count" do + post transaction_attachments_path(@transaction), params: {} + end + + assert_redirected_to transaction_path(@transaction) + assert_match "No files selected for upload", flash[:alert] + end + + test "should reject unsupported file types" do + file = fixture_file_upload("test.txt", "text/plain") + + assert_no_difference "@transaction.attachments.count" do + post transaction_attachments_path(@transaction), params: { attachment: file } + end + + assert_redirected_to transaction_path(@transaction) + assert_match "unsupported format", flash[:alert] + end + + test "should reject exceeding attachment count limit" do + # Fill up to the limit + (Transaction::MAX_ATTACHMENTS_PER_TRANSACTION).times do |i| + @transaction.attachments.attach( + io: StringIO.new("content #{i}"), + filename: "file#{i}.pdf", + content_type: "application/pdf" + ) + end + + file = fixture_file_upload("test.txt", "application/pdf") + + assert_no_difference "@transaction.attachments.count" do + post transaction_attachments_path(@transaction), params: { attachment: file } + end + + assert_redirected_to transaction_path(@transaction) + assert_match "Cannot exceed #{Transaction::MAX_ATTACHMENTS_PER_TRANSACTION} attachments", flash[:alert] + end + + test "should show attachment for authorized user" do + @transaction.attachments.attach( + io: StringIO.new("test content"), + filename: "test.pdf", + content_type: "application/pdf" + ) + + attachment = @transaction.attachments.first + get transaction_attachment_path(@transaction, attachment) + + assert_response :redirect + end + + test "should upload attachment via turbo_stream" do + file = fixture_file_upload("test.txt", "application/pdf") + + assert_difference "@transaction.attachments.count", 1 do + post transaction_attachments_path(@transaction), params: { attachment: file }, as: :turbo_stream + end + + assert_response :success + assert_match(/turbo-stream action="replace" target="transaction_attachments_#{@transaction.id}"/, response.body) + assert_match(/turbo-stream action="append" target="notification-tray"/, response.body) + assert_match("Attachment uploaded successfully", response.body) + end + + test "should show attachment inline" do + @transaction.attachments.attach(io: StringIO.new("test"), filename: "test.pdf", content_type: "application/pdf") + attachment = @transaction.attachments.first + + get transaction_attachment_path(@transaction, attachment, disposition: :inline) + + assert_response :redirect + assert_match(/disposition=inline/, response.redirect_url) + end + + test "should show attachment as download" do + @transaction.attachments.attach(io: StringIO.new("test"), filename: "test.pdf", content_type: "application/pdf") + attachment = @transaction.attachments.first + + get transaction_attachment_path(@transaction, attachment, disposition: :attachment) + + assert_response :redirect + assert_match(/disposition=attachment/, response.redirect_url) + end + + test "should delete attachment via turbo_stream" do + @transaction.attachments.attach(io: StringIO.new("test"), filename: "test.pdf", content_type: "application/pdf") + attachment = @transaction.attachments.first + + assert_difference "@transaction.attachments.count", -1 do + delete transaction_attachment_path(@transaction, attachment), as: :turbo_stream + end + + assert_response :success + assert_match(/turbo-stream action="replace" target="transaction_attachments_#{@transaction.id}"/, response.body) + assert_match(/turbo-stream action="append" target="notification-tray"/, response.body) + assert_match("Attachment deleted successfully", response.body) + end +end diff --git a/test/controllers/transactions/categorizes_controller_test.rb b/test/controllers/transactions/categorizes_controller_test.rb new file mode 100644 index 000000000..a3628543e --- /dev/null +++ b/test/controllers/transactions/categorizes_controller_test.rb @@ -0,0 +1,268 @@ +require "test_helper" + +class Transactions::CategorizesControllerTest < ActionDispatch::IntegrationTest + include EntriesTestHelper + + setup do + sign_in @user = users(:family_admin) + @family = @user.family + @account = accounts(:depository) + @category = categories(:food_and_drink) + # Clear entries for isolation + @family.accounts.each { |a| a.entries.delete_all } + end + + # GET /transactions/categorize + + test "show redirects with notice when nothing to categorize" do + get transactions_categorize_url + assert_redirected_to transactions_url + assert_match "categorized", flash[:notice] + end + + test "show renders wizard when uncategorized transactions exist" do + create_transaction(account: @account, name: "Starbucks") + get transactions_categorize_url + assert_response :success + end + + test "show renders the first group at position 0" do + 2.times { create_transaction(account: @account, name: "Netflix") } + 3.times { create_transaction(account: @account, name: "Starbucks") } + + get transactions_categorize_url(position: 0) + + assert_response :success + assert_select "h2", text: "Starbucks" + end + + test "show at position 1 skips first group" do + 3.times { create_transaction(account: @account, name: "Starbucks") } + 2.times { create_transaction(account: @account, name: "Netflix") } + + get transactions_categorize_url(position: 1) + + assert_response :success + assert_select "h2", text: "Netflix" + end + + test "show redirects when position exceeds available groups" do + create_transaction(account: @account, name: "Starbucks") + + get transactions_categorize_url(position: 99) + + assert_redirected_to transactions_url + end + + test "requires authentication" do + sign_out + get transactions_categorize_url + assert_redirected_to new_session_url + end + + # Account sharing authorization + + test "show only groups entries from accounts accessible to the user" do + accessible_account = accounts(:depository) # shared with family_member (full_control) + inaccessible_account = accounts(:investment) # not shared with family_member + + create_transaction(account: accessible_account, name: "Starbucks") + create_transaction(account: inaccessible_account, name: "Starbucks") + + sign_in users(:family_member) + get transactions_categorize_url(position: 0) + + assert_response :success + # Only 1 entry should appear in the group — the inaccessible account's entry is hidden + assert_select "input[name='entry_ids[]']", count: 1 + end + + test "create does not categorize entries from inaccessible accounts" do + inaccessible_account = accounts(:investment) # not shared with family_member + entry = create_transaction(account: inaccessible_account, name: "Starbucks") + + sign_in users(:family_member) + post transactions_categorize_url, + params: { + position: 0, + grouping_key: "Starbucks", + entry_ids: [ entry.id ], + all_entry_ids: [ entry.id ], + category_id: @category.id + }, + headers: { "Accept" => "text/vnd.turbo-stream.html" } + + assert_nil entry.transaction.reload.category + end + + test "assign_entry does not categorize an entry from an inaccessible account" do + inaccessible_account = accounts(:investment) # not shared with family_member + entry = create_transaction(account: inaccessible_account, name: "Starbucks") + + sign_in users(:family_member) + patch assign_entry_transactions_categorize_url, params: { + entry_id: entry.id, + category_id: @category.id, + position: 0, + all_entry_ids: [ entry.id ] + } + + assert_response :not_found + assert_nil entry.transaction.reload.category + end + + # GET /transactions/categorize/preview_rule + + test "preview_rule returns matching entries for a filter" do + create_transaction(account: @account, name: "Amazon Prime") + create_transaction(account: @account, name: "Amazon Music") + create_transaction(account: @account, name: "Starbucks") + + get preview_rule_transactions_categorize_url(filter: "Amazon"), + headers: { "Accept" => "text/vnd.turbo-stream.html" } + + assert_response :success + assert_includes response.body, "Amazon Prime" + assert_includes response.body, "Amazon Music" + assert_not_includes response.body, "Starbucks" + end + + test "preview_rule returns empty list for blank filter" do + create_transaction(account: @account, name: "Amazon") + + get preview_rule_transactions_categorize_url(filter: ""), + headers: { "Accept" => "text/vnd.turbo-stream.html" } + + assert_response :success + assert_not_includes response.body, "Amazon" + end + + test "preview_rule requires authentication" do + sign_out + get preview_rule_transactions_categorize_url(filter: "Amazon") + assert_redirected_to new_session_url + end + + private + + def sign_out + @user.sessions.each { |s| delete session_path(s) } + end + + # POST /transactions/categorize + + test "create categorizes selected entries and returns redirect stream when all assigned" do + entry = create_transaction(account: @account, name: "Starbucks") + + post transactions_categorize_url, + params: { + position: 0, + grouping_key: "Starbucks", + entry_ids: [ entry.id ], + all_entry_ids: [ entry.id ], + category_id: @category.id + }, + headers: { "Accept" => "text/vnd.turbo-stream.html" } + + assert_response :success + assert_equal @category, entry.transaction.reload.category + assert_includes response.body, "action=\"redirect\"" + end + + test "create removes assigned rows and replaces remaining when partial assignment" do + entry1 = create_transaction(account: @account, name: "Starbucks") + entry2 = create_transaction(account: @account, name: "Starbucks") + + post transactions_categorize_url, + params: { + position: 0, + grouping_key: "Starbucks", + entry_ids: [ entry1.id ], + all_entry_ids: [ entry1.id, entry2.id ], + category_id: @category.id + }, + headers: { "Accept" => "text/vnd.turbo-stream.html" } + + assert_response :success + assert_equal @category, entry1.transaction.reload.category + assert_nil entry2.transaction.reload.category + # Remove stream for categorized entry + assert_includes response.body, "categorize_entry_#{entry1.id}" + # Replace stream for remaining entry (re-checked) + assert_includes response.body, "categorize_entry_#{entry2.id}" + # No redirect stream — still in the group + assert_not_includes response.body, "action=\"redirect\"" + end + + test "create with create_rule param creates rule with name and type conditions" do + entry = create_transaction(account: @account, name: "Netflix", amount: 15) + + assert_difference "@family.rules.count", 1 do + post transactions_categorize_url, + params: { + position: 0, + grouping_key: "Netflix", + transaction_type: "expense", + entry_ids: [ entry.id ], + all_entry_ids: [ entry.id ], + category_id: @category.id, + create_rule: "1" + }, + headers: { "Accept" => "text/vnd.turbo-stream.html" } + end + + rule = @family.rules.find_by(name: "Netflix") + assert_not_nil rule + assert rule.active + assert rule.conditions.any? { |c| c.condition_type == "transaction_name" && c.value == "Netflix" } + assert rule.conditions.any? { |c| c.condition_type == "transaction_type" && c.value == "expense" } + end + + test "create falls back to html redirect without turbo stream header" do + entry = create_transaction(account: @account, name: "Starbucks") + + post transactions_categorize_url, params: { + position: 0, + grouping_key: "Starbucks", + entry_ids: [ entry.id ], + all_entry_ids: [ entry.id ], + category_id: @category.id + } + + assert_redirected_to transactions_categorize_url(position: 0) + assert flash[:notice].present? + end + + # PATCH /transactions/categorize/assign_entry + + test "assign_entry categorizes single entry and returns remove stream" do + entry = create_transaction(account: @account, name: "Starbucks") + other = create_transaction(account: @account, name: "Starbucks") + + patch assign_entry_transactions_categorize_url, params: { + entry_id: entry.id, + category_id: @category.id, + position: 0, + all_entry_ids: [ entry.id, other.id ] + } + + assert_response :success + assert_equal @category, entry.transaction.reload.category + assert_includes response.body, "categorize_entry_#{entry.id}" + assert_not_includes response.body, "action=\"redirect\"" + end + + test "assign_entry returns redirect stream when last entry in group" do + entry = create_transaction(account: @account, name: "Starbucks") + + patch assign_entry_transactions_categorize_url, params: { + entry_id: entry.id, + category_id: @category.id, + position: 0, + all_entry_ids: [ entry.id ] + } + + assert_response :success + assert_includes response.body, "action=\"redirect\"" + end +end diff --git a/test/controllers/transactions_controller_test.rb b/test/controllers/transactions_controller_test.rb index 2e3cf5e51..8bb91aa85 100644 --- a/test/controllers/transactions_controller_test.rb +++ b/test/controllers/transactions_controller_test.rb @@ -159,10 +159,12 @@ end totals = OpenStruct.new( count: 1, expense_money: Money.new(10000, "USD"), - income_money: Money.new(0, "USD") + income_money: Money.new(0, "USD"), + transfer_inflow_money: Money.new(0, "USD"), + transfer_outflow_money: Money.new(0, "USD") ) - Transaction::Search.expects(:new).with(family, filters: {}).returns(search) + Transaction::Search.expects(:new).with(family, filters: {}, accessible_account_ids: [ account.id ]).returns(search) search.expects(:totals).once.returns(totals) get transactions_url @@ -181,16 +183,43 @@ end totals = OpenStruct.new( count: 1, expense_money: Money.new(10000, "USD"), - income_money: Money.new(0, "USD") + income_money: Money.new(0, "USD"), + transfer_inflow_money: Money.new(0, "USD"), + transfer_outflow_money: Money.new(0, "USD") ) - Transaction::Search.expects(:new).with(family, filters: { "categories" => [ "Food" ], "types" => [ "expense" ] }).returns(search) + Transaction::Search.expects(:new).with(family, filters: { "categories" => [ "Food" ], "types" => [ "expense" ] }, accessible_account_ids: [ account.id ]).returns(search) search.expects(:totals).once.returns(totals) get transactions_url(q: { categories: [ "Food" ], types: [ "expense" ] }) assert_response :success end + test "shows inflow/outflow labels when filtering by transfers only" do + family = families(:empty) + sign_in users(:empty) + account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new + + create_transaction(account: account, amount: 100) + + search = Transaction::Search.new(family, filters: { "types" => [ "transfer" ] }) + totals = OpenStruct.new( + count: 2, + expense_money: Money.new(0, "USD"), + income_money: Money.new(0, "USD"), + transfer_inflow_money: Money.new(5000, "USD"), + transfer_outflow_money: Money.new(3000, "USD") + ) + + Transaction::Search.expects(:new).with(family, filters: { "types" => [ "transfer" ] }, accessible_account_ids: [ account.id ]).returns(search) + search.expects(:totals).once.returns(totals) + + get transactions_url(q: { types: [ "transfer" ] }) + assert_response :success + assert_select "#total-income", text: totals.transfer_inflow_money.format + assert_select "#total-expense", text: totals.transfer_outflow_money.format + end + test "mark_as_recurring creates a manual recurring transaction" do family = families(:empty) sign_in users(:empty) @@ -223,6 +252,7 @@ end # Create existing recurring transaction family.recurring_transactions.create!( + account: account, merchant: merchant, amount: entry.amount, currency: entry.currency, @@ -309,6 +339,38 @@ end assert_not entry.protected_from_sync? end + test "new with duplicate_entry_id pre-fills form from source transaction" do + @entry.reload + + get new_transaction_url(duplicate_entry_id: @entry.id) + assert_response :success + assert_select "input[name='entry[name]'][value=?]", @entry.name + assert_select "input[type='number'][name='entry[amount]']" do |elements| + assert_equal sprintf("%.2f", @entry.amount.abs), elements.first["value"] + end + assert_select "input[type='hidden'][name='entry[entryable_attributes][merchant_id]']" + end + + test "new with invalid duplicate_entry_id renders empty form" do + get new_transaction_url(duplicate_entry_id: -1) + assert_response :success + assert_select "input[name='entry[name]']" do |elements| + assert_nil elements.first["value"] + end + end + + test "new with duplicate_entry_id from another family does not prefill form" do + other_family = families(:empty) + other_account = other_family.accounts.create!(name: "Other", balance: 0, currency: "USD", accountable: Depository.new) + other_entry = create_transaction(account: other_account, name: "Should not leak", amount: 50) + + get new_transaction_url(duplicate_entry_id: other_entry.id) + assert_response :success + assert_select "input[name='entry[name]']" do |elements| + assert_nil elements.first["value"] + end + end + test "unlock clears import_locked flag" do family = families(:empty) sign_in users(:empty) diff --git a/test/fixtures/account_shares.yml b/test/fixtures/account_shares.yml new file mode 100644 index 000000000..200af469d --- /dev/null +++ b/test/fixtures/account_shares.yml @@ -0,0 +1,13 @@ +# Share the checking account with family_member (full control) +depository_shared_with_member: + account: depository + user: family_member + permission: full_control + include_in_finances: true + +# Share the credit card with family_member (read only) +credit_card_shared_with_member: + account: credit_card + user: family_member + permission: read_only + include_in_finances: true diff --git a/test/fixtures/accounts.yml b/test/fixtures/accounts.yml index 3b0354d39..e2554e5dc 100644 --- a/test/fixtures/accounts.yml +++ b/test/fixtures/accounts.yml @@ -1,5 +1,6 @@ other_asset: family: dylan_family + owner: family_admin name: Collectable Account balance: 550 currency: USD @@ -9,6 +10,7 @@ other_asset: other_liability: family: dylan_family + owner: family_admin name: IOU (personal debt to friend) balance: 200 currency: USD @@ -18,6 +20,7 @@ other_liability: depository: family: dylan_family + owner: family_admin name: Checking Account balance: 5000 currency: USD @@ -27,6 +30,7 @@ depository: connected: family: dylan_family + owner: family_admin name: Plaid Depository Account balance: 5000 currency: USD @@ -37,6 +41,7 @@ connected: credit_card: family: dylan_family + owner: family_admin name: Credit Card balance: 1000 currency: USD @@ -46,6 +51,7 @@ credit_card: investment: family: dylan_family + owner: family_admin name: Robinhood Brokerage Account balance: 10000 cash_balance: 5000 @@ -56,6 +62,7 @@ investment: loan: family: dylan_family + owner: family_admin name: Mortgage Loan balance: 500000 currency: USD @@ -65,6 +72,7 @@ loan: property: family: dylan_family + owner: family_admin name: 123 Maybe Court balance: 550000 currency: USD @@ -74,6 +82,7 @@ property: vehicle: family: dylan_family + owner: family_admin name: Honda Accord balance: 18000 currency: USD @@ -83,6 +92,7 @@ vehicle: crypto: family: dylan_family + owner: family_admin name: Bitcoin balance: 10000 currency: USD diff --git a/test/fixtures/binance_accounts.yml b/test/fixtures/binance_accounts.yml new file mode 100644 index 000000000..84ad80061 --- /dev/null +++ b/test/fixtures/binance_accounts.yml @@ -0,0 +1,6 @@ +one: + binance_item: one + name: Binance + account_type: combined + currency: USD + current_balance: 15000.00 diff --git a/test/fixtures/binance_items.yml b/test/fixtures/binance_items.yml new file mode 100644 index 000000000..d911d9441 --- /dev/null +++ b/test/fixtures/binance_items.yml @@ -0,0 +1,18 @@ +one: + family: dylan_family + name: My Binance + api_key: test_api_key_123 + api_secret: test_api_secret_456 + status: good + institution_name: Binance + institution_domain: binance.com + institution_url: https://www.binance.com + institution_color: "#F0B90B" + +requires_update: + family: dylan_family + name: Stale Binance + api_key: old_key + api_secret: old_secret + status: requires_update + institution_name: Binance diff --git a/test/fixtures/categories.yml b/test/fixtures/categories.yml index 958e450e3..fb5bd41c3 100644 --- a/test/fixtures/categories.yml +++ b/test/fixtures/categories.yml @@ -1,6 +1,6 @@ one: name: Test - family: empty + family: dylan_family income: name: Income diff --git a/test/fixtures/families.yml b/test/fixtures/families.yml index 10d5bd184..be4598bae 100644 --- a/test/fixtures/families.yml +++ b/test/fixtures/families.yml @@ -3,3 +3,7 @@ empty: dylan_family: name: The Dylan Family + +inactive_trial: + name: Inactive Trial Family + created_at: <%= 90.days.ago %> diff --git a/test/fixtures/files/test.txt b/test/fixtures/files/test.txt new file mode 100644 index 000000000..69fadbfa4 --- /dev/null +++ b/test/fixtures/files/test.txt @@ -0,0 +1 @@ +This is a test file for attachment uploads. diff --git a/test/fixtures/imports.yml b/test/fixtures/imports.yml index 964585593..a63f9a5de 100644 --- a/test/fixtures/imports.yml +++ b/test/fixtures/imports.yml @@ -45,3 +45,8 @@ pdf_with_rows: category: "Income" notes: "" rows_count: 2 + +sure: + family: dylan_family + type: SureImport + status: pending diff --git a/test/fixtures/indexa_capital_items.yml b/test/fixtures/indexa_capital_items.yml index 478d53500..29e2238c6 100644 --- a/test/fixtures/indexa_capital_items.yml +++ b/test/fixtures/indexa_capital_items.yml @@ -2,6 +2,7 @@ configured_with_token: family: dylan_family + name: "Indexa Capital Connection" api_token: "test_api_token_123" status: good @@ -9,6 +10,7 @@ configured_with_token: configured_with_credentials: family: empty + name: "Indexa Capital Credentials" username: "testuser@example.com" document: "12345678A" diff --git a/test/fixtures/lunchflow_items.yml b/test/fixtures/lunchflow_items.yml index fa7e12824..b6a32acbf 100644 --- a/test/fixtures/lunchflow_items.yml +++ b/test/fixtures/lunchflow_items.yml @@ -1,5 +1,6 @@ one: family: dylan_family + name: "Test Lunchflow Connection" api_key: "test_api_key_123" status: good diff --git a/test/fixtures/merchants.yml b/test/fixtures/merchants.yml index 6ac64ef48..40b20c44d 100644 --- a/test/fixtures/merchants.yml +++ b/test/fixtures/merchants.yml @@ -1,7 +1,7 @@ one: type: FamilyMerchant name: Test - family: empty + family: dylan_family netflix: type: FamilyMerchant diff --git a/test/fixtures/mercury_items.yml b/test/fixtures/mercury_items.yml index f5c31bb63..f8eec0f45 100644 --- a/test/fixtures/mercury_items.yml +++ b/test/fixtures/mercury_items.yml @@ -1,5 +1,6 @@ one: family: dylan_family + name: "Test Mercury Connection" token: "test_mercury_token_123" base_url: "https://api-sandbox.mercury.com/api/v1" diff --git a/test/fixtures/messages.yml b/test/fixtures/messages.yml index cf3c6df8b..4145d3cf7 100644 --- a/test/fixtures/messages.yml +++ b/test/fixtures/messages.yml @@ -1,17 +1,3 @@ -chat1_developer: - type: DeveloperMessage - content: You are a personal finance assistant. Be concise and helpful. - chat: one - created_at: 2025-03-20 12:00:00 - debug: false - -chat1_developer_debug: - type: DeveloperMessage - content: An internal debug message - chat: one - created_at: 2025-03-20 12:00:02 - debug: true - chat1_user: type: UserMessage content: Can you help me understand my spending habits? diff --git a/test/fixtures/plaid_items.yml b/test/fixtures/plaid_items.yml index 03e7cdfb3..93605103b 100644 --- a/test/fixtures/plaid_items.yml +++ b/test/fixtures/plaid_items.yml @@ -1,5 +1,6 @@ one: family: dylan_family + plaid_id: "item_mock_1" access_token: encrypted_token_1 name: "Test Bank" diff --git a/test/fixtures/recurring_transactions.yml b/test/fixtures/recurring_transactions.yml index 768012871..f832a71d5 100644 --- a/test/fixtures/recurring_transactions.yml +++ b/test/fixtures/recurring_transactions.yml @@ -1,5 +1,6 @@ netflix_subscription: family: dylan_family + account: depository merchant: netflix amount: 15.99 currency: USD @@ -11,6 +12,7 @@ netflix_subscription: inactive_subscription: family: dylan_family + account: credit_card merchant: amazon amount: 9.99 currency: USD diff --git a/test/fixtures/snaptrade_items.yml b/test/fixtures/snaptrade_items.yml index f1c0dfc70..df3753dbe 100644 --- a/test/fixtures/snaptrade_items.yml +++ b/test/fixtures/snaptrade_items.yml @@ -3,6 +3,7 @@ configured_item: family: dylan_family + name: "SnapTrade Connection" client_id: "test_client_id" consumer_key: "test_consumer_key" @@ -17,6 +18,7 @@ configured_item: # but before connecting to SnapTrade portal) pending_registration_item: family: empty + name: "Pending Registration" client_id: "pending_client_id" consumer_key: "pending_consumer_key" diff --git a/test/fixtures/subscriptions.yml b/test/fixtures/subscriptions.yml index 333ba7fe7..7d7b7c612 100644 --- a/test/fixtures/subscriptions.yml +++ b/test/fixtures/subscriptions.yml @@ -1,9 +1,14 @@ active: - family: dylan_family - status: active + family: dylan_family + status: active stripe_id: "test_1234567890" trialing: family: empty status: trialing trial_ends_at: <%= 12.days.from_now %> + +expired_trial: + family: inactive_trial + status: paused + trial_ends_at: <%= 45.days.ago %> diff --git a/test/fixtures/tags.yml b/test/fixtures/tags.yml index 1c76d6cce..56d1c43b1 100644 --- a/test/fixtures/tags.yml +++ b/test/fixtures/tags.yml @@ -8,4 +8,4 @@ two: three: name: Test - family: empty \ No newline at end of file + family: dylan_family \ No newline at end of file diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index dc55cfc0f..109b78a13 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -77,6 +77,19 @@ intro_user: show_ai_sidebar: false ui_layout: intro +inactive_trial_user: + family: inactive_trial + first_name: Inactive + last_name: User + email: inactive@example.com + password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla + role: admin + onboarded_at: <%= 90.days.ago %> + ai_enabled: true + show_sidebar: true + show_ai_sidebar: true + ui_layout: dashboard + # SSO-only user: created via JIT provisioning, no local password sso_only: family: empty diff --git a/test/integration/active_storage_authorization_test.rb b/test/integration/active_storage_authorization_test.rb new file mode 100644 index 000000000..75698a536 --- /dev/null +++ b/test/integration/active_storage_authorization_test.rb @@ -0,0 +1,58 @@ +require "test_helper" + +class ActiveStorageAuthorizationTest < ActionDispatch::IntegrationTest + setup do + @user_a = users(:family_admin) # In dylan_family + @user_b = users(:empty) # In empty family + + @transaction_a = transactions(:one) # Assuming it belongs to dylan_family via its entry/account + @transaction_a.attachments.attach( + io: StringIO.new("Family A Secret Receipt"), + filename: "receipt.pdf", + content_type: "application/pdf" + ) + @attachment_a = @transaction_a.attachments.first + end + + test "user can access attachments within their own family" do + sign_in @user_a + + # Get the redirect URL from our controller + get transaction_attachment_path(@transaction_a, @attachment_a) + assert_response :redirect + + # Follow the redirect to ActiveStorage::Blobs::RedirectController + follow_redirect! + + # In test/local environment, it will redirect again to a disk URL + assert_response :redirect + assert_match(/rails\/active_storage\/disk/, response.header["Location"]) + end + + test "user cannot access attachments from a different family" do + sign_in @user_b + + # Even if they find the signed global ID (which is hard but possible), + # the monkey patch should block them at the blob controller level. + # We bypass our controller and go straight to the blob serving URL to test the security layer + get rails_blob_path(@attachment_a) + + # The monkey patch raises ActiveRecord::RecordNotFound which rails converts to 404 + assert_response :not_found + end + + test "user cannot access variants from a different family" do + # Attach an image to test variants + file = File.open(Rails.root.join("test/fixtures/files/square-placeholder.png")) + @transaction_a.attachments.attach(io: file, filename: "test.png", content_type: "image/png") + attachment = @transaction_a.attachments.last + variant = attachment.variant(resize_to_limit: [ 100, 100 ]).processed + + sign_in @user_b + + # Straight to the representation URL + get rails_representation_path(variant) + + assert_response :not_found + end +end diff --git a/test/javascript/parse_locale_float_test.mjs b/test/javascript/parse_locale_float_test.mjs new file mode 100644 index 000000000..5d88d9e55 --- /dev/null +++ b/test/javascript/parse_locale_float_test.mjs @@ -0,0 +1,210 @@ +import { describe, it } from "node:test" +import assert from "node:assert/strict" + +// Inline the function to avoid needing a bundler for ESM imports. +// Must be kept in sync with app/javascript/utils/parse_locale_float.js +function parseLocaleFloat(value, { separator } = {}) { + if (typeof value !== "string") return Number.parseFloat(value) || 0 + + const cleaned = value.replace(/\s/g, "") + + if (separator === ",") { + return Number.parseFloat(cleaned.replace(/\./g, "").replace(",", ".")) || 0 + } + if (separator === ".") { + return Number.parseFloat(cleaned.replace(/,/g, "")) || 0 + } + + const lastComma = cleaned.lastIndexOf(",") + const lastDot = cleaned.lastIndexOf(".") + + if (lastComma > lastDot) { + const digitsAfterComma = cleaned.length - lastComma - 1 + if (lastDot === -1 && digitsAfterComma === 3) { + return Number.parseFloat(cleaned.replace(/,/g, "")) || 0 + } + + return Number.parseFloat(cleaned.replace(/\./g, "").replace(",", ".")) || 0 + } + + return Number.parseFloat(cleaned.replace(/,/g, "")) || 0 +} + +describe("parseLocaleFloat", () => { + describe("dot as decimal separator", () => { + it("parses simple decimal", () => { + assert.equal(parseLocaleFloat("256.54"), 256.54) + }) + + it("parses with thousands comma", () => { + assert.equal(parseLocaleFloat("1,234.56"), 1234.56) + }) + + it("parses multiple thousands separators", () => { + assert.equal(parseLocaleFloat("1,234,567.89"), 1234567.89) + }) + + it("parses integer with dot-zero", () => { + assert.equal(parseLocaleFloat("100.00"), 100) + }) + }) + + describe("comma as decimal separator (European/French)", () => { + it("parses simple decimal", () => { + assert.equal(parseLocaleFloat("256,54"), 256.54) + }) + + it("parses with thousands dot", () => { + assert.equal(parseLocaleFloat("1.234,56"), 1234.56) + }) + + it("parses multiple thousands separators", () => { + assert.equal(parseLocaleFloat("1.234.567,89"), 1234567.89) + }) + + it("parses two-digit decimal", () => { + assert.equal(parseLocaleFloat("10,50"), 10.5) + }) + + it("parses single-digit decimal", () => { + assert.equal(parseLocaleFloat("10,5"), 10.5) + }) + }) + + describe("ambiguous comma with 3 trailing digits treated as thousands separator", () => { + it("treats 1,234 as one thousand two hundred thirty-four", () => { + assert.equal(parseLocaleFloat("1,234"), 1234) + }) + + it("treats 12,345 as twelve thousand three hundred forty-five", () => { + assert.equal(parseLocaleFloat("12,345"), 12345) + }) + + it("treats 1,000 as one thousand", () => { + assert.equal(parseLocaleFloat("1,000"), 1000) + }) + + it("treats 1,000,000 as one million", () => { + assert.equal(parseLocaleFloat("1,000,000"), 1000000) + }) + }) + + describe("integers", () => { + it("parses plain integer", () => { + assert.equal(parseLocaleFloat("100"), 100) + }) + + it("parses zero", () => { + assert.equal(parseLocaleFloat("0"), 0) + }) + }) + + describe("whitespace handling", () => { + it("strips leading/trailing spaces", () => { + assert.equal(parseLocaleFloat(" 256.54 "), 256.54) + }) + + it("strips thousands space separator", () => { + assert.equal(parseLocaleFloat("1 234,56"), 1234.56) + }) + }) + + describe("negative numbers", () => { + it("parses negative dot-decimal", () => { + assert.equal(parseLocaleFloat("-1,234.56"), -1234.56) + }) + + it("parses negative comma-decimal", () => { + assert.equal(parseLocaleFloat("-1.234,56"), -1234.56) + }) + + it("parses simple negative", () => { + assert.equal(parseLocaleFloat("-256.54"), -256.54) + }) + + it("parses negative European simple", () => { + assert.equal(parseLocaleFloat("-256,54"), -256.54) + }) + }) + + describe("with separator hint", () => { + describe("comma separator (European currencies like EUR)", () => { + const opts = { separator: "," } + + it("disambiguates 1,234 as 1.234 (European decimal)", () => { + assert.equal(parseLocaleFloat("1,234", opts), 1.234) + }) + + it("parses 1.234,56 correctly", () => { + assert.equal(parseLocaleFloat("1.234,56", opts), 1234.56) + }) + + it("parses simple comma decimal", () => { + assert.equal(parseLocaleFloat("256,54", opts), 256.54) + }) + + it("parses integer without separators", () => { + assert.equal(parseLocaleFloat("1234", opts), 1234) + }) + + it("parses negative value", () => { + assert.equal(parseLocaleFloat("-1.234,56", opts), -1234.56) + }) + }) + + describe("dot separator (English currencies like USD)", () => { + const opts = { separator: "." } + + it("disambiguates 1,234 as 1234 (English thousands)", () => { + assert.equal(parseLocaleFloat("1,234", opts), 1234) + }) + + it("parses 1,234.56 correctly", () => { + assert.equal(parseLocaleFloat("1,234.56", opts), 1234.56) + }) + + it("parses simple dot decimal", () => { + assert.equal(parseLocaleFloat("256.54", opts), 256.54) + }) + + it("parses integer without separators", () => { + assert.equal(parseLocaleFloat("1234", opts), 1234) + }) + + it("parses negative value", () => { + assert.equal(parseLocaleFloat("-1,234.56", opts), -1234.56) + }) + }) + + it("falls back to heuristic when no hint given", () => { + assert.equal(parseLocaleFloat("1,234"), 1234) + assert.equal(parseLocaleFloat("256,54"), 256.54) + }) + }) + + describe("edge cases", () => { + it("returns 0 for empty string", () => { + assert.equal(parseLocaleFloat(""), 0) + }) + + it("returns 0 for non-numeric string", () => { + assert.equal(parseLocaleFloat("abc"), 0) + }) + + it("returns 0 for undefined", () => { + assert.equal(parseLocaleFloat(undefined), 0) + }) + + it("returns 0 for null", () => { + assert.equal(parseLocaleFloat(null), 0) + }) + + it("passes through numeric values", () => { + assert.equal(parseLocaleFloat(42.5), 42.5) + }) + + it("returns 0 for NaN", () => { + assert.equal(parseLocaleFloat(NaN), 0) + }) + }) +}) diff --git a/test/jobs/demo_family_refresh_job_test.rb b/test/jobs/demo_family_refresh_job_test.rb new file mode 100644 index 000000000..991b2ed3c --- /dev/null +++ b/test/jobs/demo_family_refresh_job_test.rb @@ -0,0 +1,64 @@ +require "test_helper" + +class DemoFamilyRefreshJobTest < ActiveJob::TestCase + setup do + @demo_email = "demo-user@example.com" + Rails.application.stubs(:config_for).with(:demo).returns({ "email" => @demo_email }) + + @demo_family = Family.create!(name: "Demo Family") + @demo_user = @demo_family.users.create!( + first_name: "Demo", + last_name: "Admin", + email: @demo_email, + password: "password123", + role: :admin, + onboarded_at: Time.current, + ai_enabled: true, + show_sidebar: true, + show_ai_sidebar: true, + ui_layout: :dashboard + ) + + @super_admin = families(:dylan_family).users.create!( + first_name: "Super", + last_name: "Admin", + email: "super-admin@example.com", + password: "password123", + role: :super_admin, + onboarded_at: Time.current, + ai_enabled: true, + show_sidebar: true, + show_ai_sidebar: true, + ui_layout: :dashboard + ) + end + + test "anonymizes old demo user email, enqueues deletion, regenerates data, and notifies super admins" do + travel_to Time.utc(2026, 1, 1, 5, 0, 0) do + Session.create!(user: @demo_user) + Family.create!(name: "New Family Today", created_at: 6.hours.ago) + Family.create!(name: "Old Family", created_at: 2.days.ago) + @demo_user.api_keys.create!( + name: "monitoring", + key: ApiKey::DEMO_MONITORING_KEY, + scopes: [ "read" ], + source: "monitoring" + ) + + generator = mock + generator.expects(:generate_default_data!).with(skip_clear: true, email: @demo_email) do + assert_nil ApiKey.find_by(display_key: ApiKey::DEMO_MONITORING_KEY) + end + Demo::Generator.expects(:new).returns(generator) + + assert_enqueued_with(job: DestroyJob, args: [ @demo_family ]) do + assert_enqueued_jobs 2, only: ActionMailer::MailDeliveryJob do + DemoFamilyRefreshJob.perform_now + end + end + + assert_not_equal @demo_email, @demo_user.reload.email + assert_match(/\+deleting-/, @demo_user.email) + end + end +end diff --git a/test/jobs/inactive_family_cleaner_job_test.rb b/test/jobs/inactive_family_cleaner_job_test.rb new file mode 100644 index 000000000..5a34307f4 --- /dev/null +++ b/test/jobs/inactive_family_cleaner_job_test.rb @@ -0,0 +1,149 @@ +require "test_helper" + +class InactiveFamilyCleanerJobTest < ActiveJob::TestCase + setup do + @inactive_family = families(:inactive_trial) + @inactive_user = users(:inactive_trial_user) + Rails.application.config.stubs(:app_mode).returns("managed".inquiry) + end + + test "skips in self-hosted mode" do + Rails.application.config.stubs(:app_mode).returns("self_hosted".inquiry) + + assert_no_difference "Family.count" do + InactiveFamilyCleanerJob.perform_now + end + end + + test "destroys empty post-trial family with no accounts" do + assert_equal 0, @inactive_family.accounts.count + + assert_difference "Family.count", -1 do + InactiveFamilyCleanerJob.perform_now + end + + assert_not Family.exists?(@inactive_family.id) + end + + test "does not create archive for family with no accounts" do + assert_no_difference "ArchivedExport.count" do + InactiveFamilyCleanerJob.perform_now + end + end + + test "destroys family with accounts but few transactions" do + account = @inactive_family.accounts.create!( + name: "Test", currency: "USD", balance: 0, accountable: Depository.new, status: :active + ) + # Add only 5 transactions (below threshold of 12) + 5.times do |i| + account.entries.create!( + name: "Txn #{i}", date: 50.days.ago + i.days, amount: 10, currency: "USD", + entryable: Transaction.new + ) + end + + assert_no_difference "ArchivedExport.count" do + assert_difference "Family.count", -1 do + InactiveFamilyCleanerJob.perform_now + end + end + end + + test "archives then destroys family with 12+ recent transactions" do + account = @inactive_family.accounts.create!( + name: "Test", currency: "USD", balance: 0, accountable: Depository.new, status: :active + ) + + trial_end = @inactive_family.subscription.trial_ends_at + # Create 15 transactions, some within last 14 days of trial + 15.times do |i| + account.entries.create!( + name: "Txn #{i}", date: trial_end - i.days, amount: 10, currency: "USD", + entryable: Transaction.new + ) + end + + assert_difference "ArchivedExport.count", 1 do + assert_difference "Family.count", -1 do + InactiveFamilyCleanerJob.perform_now + end + end + + archive = ArchivedExport.last + assert_equal "inactive@example.com", archive.email + assert_equal "Inactive Trial Family", archive.family_name + assert archive.export_file.attached? + assert archive.download_token_digest.present? + assert archive.expires_at > 89.days.from_now + end + + test "preserves families with active subscriptions" do + dylan_family = families(:dylan_family) + assert dylan_family.subscription.active? + + InactiveFamilyCleanerJob.perform_now + + assert Family.exists?(dylan_family.id) + end + + test "preserves families still within grace period" do + @inactive_family.subscription.update!(trial_ends_at: 5.days.ago) + + initial_count = Family.count + InactiveFamilyCleanerJob.perform_now + + assert Family.exists?(@inactive_family.id) + end + + test "destroys families with no subscription created long ago" do + old_family = Family.create!(name: "Abandoned", created_at: 90.days.ago) + old_family.users.create!( + first_name: "Old", last_name: "User", email: "old-abandoned@example.com", + password: "password123", role: :admin, onboarded_at: 90.days.ago, + ai_enabled: true, show_sidebar: true, show_ai_sidebar: true, ui_layout: :dashboard + ) + # No subscription created + + assert_nil old_family.subscription + + InactiveFamilyCleanerJob.perform_now + + assert_not Family.exists?(old_family.id) + end + + test "preserves recently created families with no subscription" do + recent_family = Family.create!(name: "New Family") + recent_family.users.create!( + first_name: "New", last_name: "User", email: "newuser-recent@example.com", + password: "password123", role: :admin, onboarded_at: 1.day.ago, + ai_enabled: true, show_sidebar: true, show_ai_sidebar: true, ui_layout: :dashboard + ) + + InactiveFamilyCleanerJob.perform_now + + assert Family.exists?(recent_family.id) + + # Cleanup + recent_family.destroy + end + + test "dry run does not destroy or archive" do + account = @inactive_family.accounts.create!( + name: "Test", currency: "USD", balance: 0, accountable: Depository.new, status: :active + ) + trial_end = @inactive_family.subscription.trial_ends_at + 15.times do |i| + account.entries.create!( + name: "Txn #{i}", date: trial_end - i.days, amount: 10, currency: "USD", + entryable: Transaction.new + ) + end + + assert_no_difference [ "Family.count", "ArchivedExport.count" ] do + InactiveFamilyCleanerJob.perform_now(dry_run: true) + end + + assert Family.exists?(@inactive_family.id) + end +end diff --git a/test/jobs/simplefin_holdings_apply_job_test.rb b/test/jobs/simplefin_holdings_apply_job_test.rb index 55a4e644e..7cc7b4c6c 100644 --- a/test/jobs/simplefin_holdings_apply_job_test.rb +++ b/test/jobs/simplefin_holdings_apply_job_test.rb @@ -61,4 +61,70 @@ class SimplefinHoldingsApplyJobTest < ActiveSupport::TestCase newco_sec = Security.find_by(ticker: "NEWCO") refute_nil newco_sec, "should create NEWCO security via resolver when missing" end + + test "uses market_value for price and does not confuse value with market_value" do + # Regression test for GH #1182: some brokerages (Vanguard, Fidelity) include a + # "value" field that represents cost basis, not market value. The processor must + # use "market_value" for price derivation and treat "value" as a cost_basis fallback. + @account.holdings.delete_all + + @sfa.update!( + raw_holdings_payload: [ + { + "id" => "h_vanguard", + "symbol" => "VFIAX", + "shares" => 50, + "market_value" => 22626.42, + "cost_basis" => 22004.40, + "value" => 22004.40, + "currency" => "USD" + } + ] + ) + + assert_difference "Holding.where(account: @account).count", 1 do + SimplefinHoldingsApplyJob.perform_now(@sfa.id) + end + + holding = @account.holdings.find_by(external_id: "simplefin_h_vanguard") + refute_nil holding + + # Price should be derived from market_value / shares, NOT from value / shares + expected_price = BigDecimal("22626.42") / BigDecimal("50") + assert_in_delta expected_price.to_f, holding.price.to_f, 0.01, + "price should be market_value/qty (#{expected_price}), not value/qty" + + # Amount should reflect market_value, not cost basis + assert_in_delta 22626.42, holding.amount.to_f, 0.01 + end + + test "falls back to value for cost_basis when cost_basis field is absent" do + @account.holdings.delete_all + + @sfa.update!( + raw_holdings_payload: [ + { + "id" => "h_fallback", + "symbol" => "FXAIX", + "shares" => 100, + "market_value" => 50000, + "value" => 45000, + "currency" => "USD" + } + ] + ) + + assert_difference "Holding.where(account: @account).count", 1 do + SimplefinHoldingsApplyJob.perform_now(@sfa.id) + end + + holding = @account.holdings.find_by(external_id: "simplefin_h_fallback") + refute_nil holding + + # Price derived from market_value + assert_in_delta 500.0, holding.price.to_f, 0.01 + + # cost_basis should fall back to "value" field (45000) + assert_in_delta 45000.0, holding.cost_basis.to_f, 0.01 + end end diff --git a/test/lib/feature_flags_test.rb b/test/lib/feature_flags_test.rb new file mode 100644 index 000000000..85d0a5f90 --- /dev/null +++ b/test/lib/feature_flags_test.rb @@ -0,0 +1,26 @@ +require "test_helper" + +class FeatureFlagsTest < ActiveSupport::TestCase + test "db_sso_providers? is true when AUTH_PROVIDERS_SOURCE is db in production" do + with_env_overrides("AUTH_PROVIDERS_SOURCE" => "db") do + Rails.stubs(:env).returns(ActiveSupport::StringInquirer.new("production")) + assert FeatureFlags.db_sso_providers? + end + end + + test "db_sso_providers? defaults to yaml in production when AUTH_PROVIDERS_SOURCE is unset" do + with_env_overrides("AUTH_PROVIDERS_SOURCE" => nil) do + Rails.stubs(:env).returns(ActiveSupport::StringInquirer.new("production")) + assert_not FeatureFlags.db_sso_providers? + end + end + + test "db_sso_providers? defaults to db for self hosted mode outside production" do + with_env_overrides("AUTH_PROVIDERS_SOURCE" => nil) do + Rails.stubs(:env).returns(ActiveSupport::StringInquirer.new("development")) + with_self_hosting do + assert FeatureFlags.db_sso_providers? + end + end + end +end diff --git a/test/lib/money_test.rb b/test/lib/money_test.rb index 1a43c8e8e..5c18105aa 100644 --- a/test/lib/money_test.rb +++ b/test/lib/money_test.rb @@ -161,7 +161,7 @@ class MoneyTest < ActiveSupport::TestCase test "all supported locales can format money without errors" do # Ensure all supported locales from LanguagesHelper::SUPPORTED_LOCALES work - supported_locales = %w[en fr de es tr nb ca ro pt-BR zh-CN zh-TW nl] + supported_locales = LanguagesHelper::SUPPORTED_LOCALES supported_locales.each do |locale| locale_sym = locale.to_sym diff --git a/test/lib/polish_pluralization_test.rb b/test/lib/polish_pluralization_test.rb new file mode 100644 index 000000000..b614f8888 --- /dev/null +++ b/test/lib/polish_pluralization_test.rb @@ -0,0 +1,27 @@ +require "test_helper" +require "securerandom" + +class PolishPluralizationTest < ActiveSupport::TestCase + test "uses rails i18n plural rules for polish" do + translation_key = "test_pluralization_#{SecureRandom.hex(6)}" + + I18n.backend.store_translations(:pl, translation_key => { + sample: { + one: "one", + few: "few", + many: "many", + other: "other" + } + }) + + path = "#{translation_key}.sample" + + assert_equal "many", I18n.t(path, locale: :pl, count: 0) + assert_equal "one", I18n.t(path, locale: :pl, count: 1) + assert_equal "few", I18n.t(path, locale: :pl, count: 2) + assert_equal "many", I18n.t(path, locale: :pl, count: 5) + assert_equal "many", I18n.t(path, locale: :pl, count: 12) + assert_equal "few", I18n.t(path, locale: :pl, count: 22) + assert_equal "many", I18n.t(path, locale: :pl, count: 25) + end +end diff --git a/test/mailers/demo_family_refresh_mailer_test.rb b/test/mailers/demo_family_refresh_mailer_test.rb new file mode 100644 index 000000000..53b540bcb --- /dev/null +++ b/test/mailers/demo_family_refresh_mailer_test.rb @@ -0,0 +1,23 @@ +require "test_helper" + +class DemoFamilyRefreshMailerTest < ActionMailer::TestCase + test "completed email includes summary metrics" do + period_start = Time.utc(2026, 1, 1, 5, 0, 0) + period_end = period_start + 24.hours + + email = DemoFamilyRefreshMailer.with( + super_admin: users(:sure_support_staff), + old_family_id: families(:empty).id, + old_family_name: families(:empty).name, + old_family_session_count: 12, + newly_created_families_count: 4, + period_start:, + period_end: + ).completed + + assert_equal [ "support@sure.am" ], email.to + assert_equal "Demo family refresh completed", email.subject + assert_includes email.body.to_s, "Unique login sessions for old demo family in period: 12" + assert_includes email.body.to_s, "New family accounts created in period: 4" + end +end diff --git a/test/migrations/scope_plaid_item_uniqueness_migration_test.rb b/test/migrations/scope_plaid_item_uniqueness_migration_test.rb new file mode 100644 index 000000000..99ebc56f5 --- /dev/null +++ b/test/migrations/scope_plaid_item_uniqueness_migration_test.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require "test_helper" +require Rails.root.join("db/migrate/20260219200001_scope_plaid_item_uniqueness") + +class ScopePlaidItemUniquenessMigrationTest < ActiveSupport::TestCase + test "defines the legacy migration constant alias" do + assert_equal ScopePlaidItemUniqueness, ScopePlaidAccountUniquenessToItem + end +end diff --git a/test/models/account/chartable_test.rb b/test/models/account/chartable_test.rb index 302c603b3..103b244b7 100644 --- a/test/models/account/chartable_test.rb +++ b/test/models/account/chartable_test.rb @@ -43,4 +43,106 @@ class Account::ChartableTest < ActiveSupport::TestCase memoized_series2_cash_view = account.balance_series(period: Period.last_90_days, view: :cash_balance) memoized_series2_holdings_view = account.balance_series(period: Period.last_90_days, view: :holdings_balance) end + + test "trims placeholder history for linked investment accounts without trades" do + account = accounts(:investment) + account.entries.destroy_all + account.holdings.destroy_all + + coinstats_item = account.family.coinstats_items.create!(name: "CoinStats", api_key: "test-key") + coinstats_account = coinstats_item.coinstats_accounts.create!(name: "Provider", currency: "USD") + account.account_providers.create!(provider: coinstats_account) + + account.holdings.create!( + security: securities(:aapl), + date: 5.days.ago.to_date, + qty: 1, + price: 100, + amount: 100, + currency: "USD", + account_provider: account.account_providers.last + ) + + raw_series = Series.new( + start_date: 10.days.ago.to_date, + end_date: Date.current, + interval: "1 day", + values: [ + Series::Value.new(date: 10.days.ago.to_date, date_formatted: "", value: Money.new(0, "USD")), + Series::Value.new(date: 9.days.ago.to_date, date_formatted: "", value: Money.new(0, "USD")), + Series::Value.new(date: 8.days.ago.to_date, date_formatted: "", value: Money.new(0, "USD")), + Series::Value.new(date: 5.days.ago.to_date, date_formatted: "", value: Money.new(100, "USD")), + Series::Value.new(date: Date.current, date_formatted: "", value: Money.new(110, "USD")) + ], + favorable_direction: account.favorable_direction + ) + + builder = mock + Balance::ChartSeriesBuilder.expects(:new).returns(builder) + builder.expects(:balance_series).returns(raw_series) + + series = account.balance_series + + assert_equal 5.days.ago.to_date, series.start_date + assert_equal [ 5.days.ago.to_date, Date.current ], series.values.map(&:date) + end + + test "trims unstable provider snapshot history for linked investment accounts without trades" do + account = accounts(:investment) + account.entries.destroy_all + account.holdings.destroy_all + + coinstats_item = account.family.coinstats_items.create!(name: "CoinStats", api_key: "test-key") + coinstats_account = coinstats_item.coinstats_accounts.create!(name: "Provider", currency: "USD") + account.account_providers.create!(provider: coinstats_account) + + account.holdings.create!( + security: securities(:aapl), + date: 5.days.ago.to_date, + qty: 1, + price: 100, + amount: 100, + currency: "USD", + account_provider: account.account_providers.last + ) + account.holdings.create!( + security: securities(:aapl), + date: 4.days.ago.to_date, + qty: 1, + price: 100, + amount: 100, + currency: "USD", + account_provider: account.account_providers.last + ) + account.holdings.create!( + security: securities(:msft), + date: Date.current, + qty: 1, + price: 120, + amount: 120, + currency: "USD", + account_provider: account.account_providers.last + ) + + raw_series = Series.new( + start_date: 5.days.ago.to_date, + end_date: Date.current, + interval: "1 day", + values: [ + Series::Value.new(date: 5.days.ago.to_date, date_formatted: "", value: Money.new(100, "USD")), + Series::Value.new(date: 4.days.ago.to_date, date_formatted: "", value: Money.new(101, "USD")), + Series::Value.new(date: Date.current, date_formatted: "", value: Money.new(120, "USD")) + ], + favorable_direction: account.favorable_direction + ) + + builder = mock + Balance::ChartSeriesBuilder.expects(:new).returns(builder) + builder.expects(:balance_series).returns(raw_series) + + series = account.balance_series + + assert_equal Date.current, series.start_date + assert_equal [ Date.current ], series.values.map(&:date) + end end diff --git a/test/models/account/market_data_importer_test.rb b/test/models/account/market_data_importer_test.rb index eead24bd5..0b3f7066d 100644 --- a/test/models/account/market_data_importer_test.rb +++ b/test/models/account/market_data_importer_test.rb @@ -167,6 +167,58 @@ class Account::MarketDataImporterTest < ActiveSupport::TestCase assert_equal 0, Security::Price.where(security: security, date: trade_date).count end + test "syncs security prices for provider-held securities without trades" do + family = Family.create!(name: "Smith", currency: "USD") + + account = family.accounts.create!( + name: "Brokerage", + currency: "USD", + balance: 0, + accountable: Investment.new + ) + + security = Security.create!(ticker: "PE500", exchange_operating_mic: "XPAR") + + coinstats_item = family.coinstats_items.create!(name: "CoinStats", api_key: "test-key") + coinstats_account = coinstats_item.coinstats_accounts.create!(name: "Provider", currency: "USD") + account_provider = AccountProvider.create!(account: account, provider: coinstats_account) + + account.holdings.create!( + security: security, + date: Date.current, + qty: 1, + price: 100, + amount: 100, + currency: "USD", + account_provider: account_provider + ) + + expected_start_date = account.start_date - SECURITY_PRICE_BUFFER + end_date = Date.current.in_time_zone("America/New_York").to_date + + @provider.expects(:fetch_security_prices) + .with(symbol: security.ticker, + exchange_operating_mic: security.exchange_operating_mic, + start_date: expected_start_date, + end_date: end_date) + .returns(provider_success_response([ + OpenStruct.new(security: security, + date: account.start_date, + price: 100, + currency: "USD") + ])) + + @provider.stubs(:fetch_security_info) + .with(symbol: security.ticker, exchange_operating_mic: security.exchange_operating_mic) + .returns(provider_success_response(OpenStruct.new(name: "PE500", logo_url: "logo"))) + + @provider.stubs(:fetch_exchange_rates).returns(provider_success_response([])) + + Account::MarketDataImporter.new(account).import_all + + assert_equal 1, Security::Price.where(security: security, date: account.start_date).count + end + test "handles provider error response gracefully for exchange rates" do family = Family.create!(name: "Smith", currency: "USD") diff --git a/test/models/account_share_test.rb b/test/models/account_share_test.rb new file mode 100644 index 000000000..99763ae53 --- /dev/null +++ b/test/models/account_share_test.rb @@ -0,0 +1,61 @@ +require "test_helper" + +class AccountShareTest < ActiveSupport::TestCase + setup do + @admin = users(:family_admin) + @member = users(:family_member) + @account = accounts(:depository) + end + + test "valid share" do + # Use an account that doesn't already have a share with member + account = accounts(:investment) + account.account_shares.where(user: @member).destroy_all + share = AccountShare.new(account: account, user: @member, permission: "read_only") + assert share.valid? + end + + test "invalid permission" do + share = AccountShare.new(account: @account, user: @member, permission: "invalid") + assert_not share.valid? + assert_includes share.errors[:permission], "is not included in the list" + end + + test "cannot share with account owner" do + share = AccountShare.new(account: @account, user: @admin, permission: "read_only") + assert_not share.valid? + assert_includes share.errors[:user], "is already the owner of this account" + end + + test "cannot duplicate share for same user and account" do + # depository already shared with member via fixture + duplicate = AccountShare.new(account: @account, user: @member, permission: "read_only") + assert_not duplicate.valid? + end + + test "permission helper methods" do + share = AccountShare.new(permission: "full_control") + assert share.full_control? + assert_not share.read_write? + assert_not share.read_only? + assert share.can_annotate? + assert share.can_edit? + + share.permission = "read_write" + assert share.read_write? + assert share.can_annotate? + assert_not share.can_edit? + + share.permission = "read_only" + assert share.read_only? + assert_not share.can_annotate? + assert_not share.can_edit? + end + + test "cannot share with user from different family" do + other_user = users(:empty) + share = AccountShare.new(account: @account, user: other_user, permission: "read_only") + assert_not share.valid? + assert_includes share.errors[:user], "must be in the same family" + end +end diff --git a/test/models/account_test.rb b/test/models/account_test.rb index 5a41f432e..48bfbb6c3 100644 --- a/test/models/account_test.rb +++ b/test/models/account_test.rb @@ -6,6 +6,8 @@ class AccountTest < ActiveSupport::TestCase setup do @account = @syncable = accounts(:depository) @family = families(:dylan_family) + @admin = users(:family_admin) + @member = users(:family_member) end test "can destroy" do @@ -19,6 +21,7 @@ class AccountTest < ActiveSupport::TestCase account = Account.create_and_sync({ family: @family, + owner: @admin, name: "Test Account", balance: 100, currency: "USD", @@ -37,6 +40,7 @@ class AccountTest < ActiveSupport::TestCase account = Account.create_and_sync( { family: @family, + owner: @admin, name: "Linked Account", balance: 500, currency: "EUR", @@ -57,6 +61,7 @@ class AccountTest < ActiveSupport::TestCase account = Account.create_and_sync( { family: @family, + owner: @admin, name: "Test Account", balance: 1000, currency: "GBP", @@ -72,9 +77,32 @@ class AccountTest < ActiveSupport::TestCase assert_equal 1000, opening_anchor.entry.amount end + test "create_and_sync uses provided opening balance date" do + Account.any_instance.stubs(:sync_later) + opening_date = Time.zone.today + + account = Account.create_and_sync( + { + family: @family, + owner: @admin, + name: "Test Account", + balance: 1000, + currency: "USD", + accountable_type: "Depository", + accountable_attributes: {} + }, + skip_initial_sync: true, + opening_balance_date: opening_date + ) + + opening_anchor = account.valuations.opening_anchor.first + assert_equal opening_date, opening_anchor.entry.date + end + test "gets short/long subtype label" do investment = Investment.new(subtype: "hsa") account = @family.accounts.create!( + owner: @admin, name: "Test Investment", balance: 1000, currency: "USD", @@ -95,6 +123,7 @@ class AccountTest < ActiveSupport::TestCase test "tax_treatment delegates to accountable for Investment" do investment = Investment.new(subtype: "401k") account = @family.accounts.create!( + owner: @admin, name: "Test 401k", balance: 1000, currency: "USD", @@ -108,6 +137,7 @@ class AccountTest < ActiveSupport::TestCase test "tax_treatment delegates to accountable for Crypto" do crypto = Crypto.new(tax_treatment: :taxable) account = @family.accounts.create!( + owner: @admin, name: "Test Crypto", balance: 500, currency: "USD", @@ -127,6 +157,7 @@ class AccountTest < ActiveSupport::TestCase test "tax_advantaged? returns true for tax-advantaged accounts" do investment = Investment.new(subtype: "401k") account = @family.accounts.create!( + owner: @admin, name: "Test 401k", balance: 1000, currency: "USD", @@ -140,6 +171,7 @@ class AccountTest < ActiveSupport::TestCase test "tax_advantaged? returns false for taxable accounts" do investment = Investment.new(subtype: "brokerage") account = @family.accounts.create!( + owner: @admin, name: "Test Brokerage", balance: 1000, currency: "USD", @@ -172,4 +204,88 @@ class AccountTest < ActiveSupport::TestCase assert_not ActiveStorage::Attachment.exists?(attachment_id) end + + # Account sharing tests + + test "owned_by? returns true for account owner" do + assert @account.owned_by?(@admin) + assert_not @account.owned_by?(@member) + end + + test "shared_with? returns true for owner and shared users" do + assert @account.shared_with?(@admin) # owner + # depository already shared with member via fixture + assert @account.shared_with?(@member) + end + + test "shared? returns true when account has shares" do + account = accounts(:investment) + account.account_shares.destroy_all + assert_not account.shared? + + account.share_with!(@member, permission: "read_only") + assert account.shared? + end + + test "permission_for returns correct permission level" do + assert_equal :owner, @account.permission_for(@admin) + + # depository already shared with member via fixture + share = @account.account_shares.find_by(user: @member) + share.update!(permission: "read_write") + assert_equal :read_write, @account.permission_for(@member) + end + + test "accessible_by scope returns owned and shared accounts" do + # Clear existing shares for clean test + AccountShare.delete_all + + admin_accessible = @family.accounts.accessible_by(@admin) + member_accessible = @family.accounts.accessible_by(@member) + + # Admin owns all fixture accounts + assert_equal @family.accounts.count, admin_accessible.count + # Member has no access (no shares, no owned accounts) + assert_equal 0, member_accessible.count + + # Share one account + @account.share_with!(@member, permission: "read_only") + member_accessible = @family.accounts.accessible_by(@member) + assert_equal 1, member_accessible.count + assert_includes member_accessible, @account + end + + test "included_in_finances_for scope respects include_in_finances flag" do + AccountShare.delete_all + + @account.share_with!(@member, permission: "read_only", include_in_finances: true) + assert_includes @family.accounts.included_in_finances_for(@member), @account + + share = @account.account_shares.find_by(user: @member) + share.update!(include_in_finances: false) + assert_not_includes @family.accounts.included_in_finances_for(@member), @account + end + + test "auto_share_with_family creates shares for all non-owner members" do + @family.update!(default_account_sharing: "private") + + account = Account.create_and_sync({ + family: @family, + owner: @admin, + name: "New Shared Account", + balance: 100, + currency: "USD", + accountable_type: "Depository", + accountable_attributes: {} + }) + + assert_difference -> { AccountShare.count }, @family.users.where.not(id: @admin.id).count do + account.auto_share_with_family! + end + + share = account.account_shares.find_by(user: @member) + assert_not_nil share + assert_equal "read_write", share.permission + assert share.include_in_finances? + end end diff --git a/test/models/archived_export_test.rb b/test/models/archived_export_test.rb new file mode 100644 index 000000000..56485cf9b --- /dev/null +++ b/test/models/archived_export_test.rb @@ -0,0 +1,70 @@ +require "test_helper" + +class ArchivedExportTest < ActiveSupport::TestCase + test "downloadable? returns true when not expired and file attached" do + archive = ArchivedExport.create!( + email: "test@example.com", + family_name: "Test", + expires_at: 30.days.from_now + ) + archive.export_file.attach( + io: StringIO.new("test content"), + filename: "test.zip", + content_type: "application/zip" + ) + + assert archive.downloadable? + end + + test "downloadable? returns false when expired" do + archive = ArchivedExport.create!( + email: "test@example.com", + family_name: "Test", + expires_at: 1.day.ago + ) + archive.export_file.attach( + io: StringIO.new("test content"), + filename: "test.zip", + content_type: "application/zip" + ) + + assert_not archive.downloadable? + end + + test "downloadable? returns false when file not attached" do + archive = ArchivedExport.create!( + email: "test@example.com", + family_name: "Test", + expires_at: 30.days.from_now + ) + + assert_not archive.downloadable? + end + + test "expired scope returns only expired records" do + expired = ArchivedExport.create!( + email: "expired@example.com", + family_name: "Expired", + expires_at: 1.day.ago + ) + active = ArchivedExport.create!( + email: "active@example.com", + family_name: "Active", + expires_at: 30.days.from_now + ) + + results = ArchivedExport.expired + assert_includes results, expired + assert_not_includes results, active + end + + test "generates download_token automatically" do + archive = ArchivedExport.create!( + email: "test@example.com", + family_name: "Test", + expires_at: 30.days.from_now + ) + + assert archive.download_token.present? + end +end diff --git a/test/models/assistant/external/client_test.rb b/test/models/assistant/external/client_test.rb new file mode 100644 index 000000000..74f2258ea --- /dev/null +++ b/test/models/assistant/external/client_test.rb @@ -0,0 +1,283 @@ +require "test_helper" + +class Assistant::External::ClientTest < ActiveSupport::TestCase + setup do + @client = Assistant::External::Client.new( + url: "http://localhost:18789/v1/chat", + token: "test-token", + agent_id: "test-agent" + ) + end + + test "streams text chunks from SSE response" do + sse_body = <<~SSE + data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}],"model":"test-agent"} + + data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Your net worth"},"finish_reason":null}],"model":"test-agent"} + + data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":" is $124,200."},"finish_reason":null}],"model":"test-agent"} + + data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"model":"test-agent"} + + data: [DONE] + + SSE + + mock_http_streaming_response(sse_body) + + chunks = [] + model = @client.chat(messages: [ { role: "user", content: "test" } ]) do |text| + chunks << text + end + + assert_equal [ "Your net worth", " is $124,200." ], chunks + assert_equal "test-agent", model + end + + test "raises on non-200 response" do + mock_http_error_response(503, "Service Unavailable") + + assert_raises(Assistant::Error) do + @client.chat(messages: [ { role: "user", content: "test" } ]) { |_| } + end + end + + test "retries transient errors then raises Assistant::Error" do + Net::HTTP.any_instance.stubs(:request).raises(Net::OpenTimeout, "connection timed out") + + error = assert_raises(Assistant::Error) do + @client.chat(messages: [ { role: "user", content: "test" } ]) { |_| } + end + + assert_match(/temporarily unavailable/, error.message) + end + + test "does not retry after streaming has started" do + call_count = 0 + + # Custom response that yields one chunk then raises mid-stream + mock_response = Object.new + mock_response.define_singleton_method(:is_a?) { |klass| klass == Net::HTTPSuccess } + mock_response.define_singleton_method(:read_body) do |&blk| + blk.call("data: {\"choices\":[{\"delta\":{\"content\":\"partial\"}}],\"model\":\"m\"}\n\n") + raise Errno::ECONNRESET, "connection reset mid-stream" + end + + mock_http = stub("http") + mock_http.stubs(:use_ssl=) + mock_http.stubs(:open_timeout=) + mock_http.stubs(:read_timeout=) + mock_http.define_singleton_method(:request) do |_req, &blk| + call_count += 1 + blk.call(mock_response) + end + + Net::HTTP.stubs(:new).returns(mock_http) + + chunks = [] + error = assert_raises(Assistant::Error) do + @client.chat(messages: [ { role: "user", content: "test" } ]) { |t| chunks << t } + end + + assert_equal 1, call_count, "Should not retry after streaming started" + assert_equal [ "partial" ], chunks + assert_match(/connection was interrupted/, error.message) + end + + test "builds correct request payload" do + sse_body = "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}],\"model\":\"m\"}\n\ndata: [DONE]\n\n" + capture = mock_http_streaming_response(sse_body) + + @client.chat( + messages: [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi there" }, + { role: "user", content: "What is my balance?" } + ], + user: "sure-family-42" + ) { |_| } + + body = JSON.parse(capture[0].body) + assert_equal "test-agent", body["model"] + assert_equal true, body["stream"] + assert_equal 3, body["messages"].size + assert_equal "sure-family-42", body["user"] + end + + test "sets authorization header and agent_id header" do + sse_body = "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}],\"model\":\"m\"}\n\ndata: [DONE]\n\n" + capture = mock_http_streaming_response(sse_body) + + @client.chat(messages: [ { role: "user", content: "test" } ]) { |_| } + + assert_equal "Bearer test-token", capture[0]["Authorization"] + assert_equal "test-agent", capture[0]["X-Agent-Id"] + assert_equal "agent:main:main", capture[0]["X-Session-Key"] + assert_equal "text/event-stream", capture[0]["Accept"] + assert_equal "application/json", capture[0]["Content-Type"] + end + + test "omits user field when not provided" do + sse_body = "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}],\"model\":\"m\"}\n\ndata: [DONE]\n\n" + capture = mock_http_streaming_response(sse_body) + + @client.chat(messages: [ { role: "user", content: "test" } ]) { |_| } + + body = JSON.parse(capture[0].body) + assert_not body.key?("user") + end + + test "handles malformed JSON in SSE data gracefully" do + sse_body = "data: {not valid json}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"OK\"}}],\"model\":\"m\"}\n\ndata: [DONE]\n\n" + mock_http_streaming_response(sse_body) + + chunks = [] + @client.chat(messages: [ { role: "user", content: "test" } ]) { |t| chunks << t } + + assert_equal [ "OK" ], chunks + end + + test "handles SSE data: field without space after colon (spec-compliant)" do + sse_body = "data:{\"choices\":[{\"delta\":{\"content\":\"no space\"}}],\"model\":\"m\"}\n\ndata:[DONE]\n\n" + mock_http_streaming_response(sse_body) + + chunks = [] + @client.chat(messages: [ { role: "user", content: "test" } ]) { |t| chunks << t } + + assert_equal [ "no space" ], chunks + end + + test "handles chunked SSE data split across read_body calls" do + chunk1 = "data: {\"choices\":[{\"delta\":{\"content\":\"Hel" + chunk2 = "lo\"}}],\"model\":\"m\"}\n\ndata: [DONE]\n\n" + + mock_http_streaming_response_chunked([ chunk1, chunk2 ]) + + chunks = [] + @client.chat(messages: [ { role: "user", content: "test" } ]) { |t| chunks << t } + + assert_equal [ "Hello" ], chunks + end + + test "routes through HTTPS_PROXY when set" do + sse_body = "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}],\"model\":\"m\"}\n\ndata: [DONE]\n\n" + + mock_response = stub("response") + mock_response.stubs(:code).returns("200") + mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true) + mock_response.stubs(:read_body).yields(sse_body) + + mock_http = stub("http") + mock_http.stubs(:use_ssl=) + mock_http.stubs(:open_timeout=) + mock_http.stubs(:read_timeout=) + mock_http.stubs(:request).yields(mock_response) + + captured_args = nil + Net::HTTP.stubs(:new).with do |*args| + captured_args = args + true + end.returns(mock_http) + + client = Assistant::External::Client.new( + url: "https://example.com/v1/chat", + token: "test-token" + ) + + ClimateControl.modify(HTTPS_PROXY: "http://proxyuser:proxypass@proxy:8888") do + client.chat(messages: [ { role: "user", content: "test" } ]) { |_| } + end + + assert_equal "example.com", captured_args[0] + assert_equal 443, captured_args[1] + assert_equal "proxy", captured_args[2] + assert_equal 8888, captured_args[3] + assert_equal "proxyuser", captured_args[4] + assert_equal "proxypass", captured_args[5] + end + + test "skips proxy for hosts in NO_PROXY" do + sse_body = "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}],\"model\":\"m\"}\n\ndata: [DONE]\n\n" + + mock_response = stub("response") + mock_response.stubs(:code).returns("200") + mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true) + mock_response.stubs(:read_body).yields(sse_body) + + mock_http = stub("http") + mock_http.stubs(:use_ssl=) + mock_http.stubs(:open_timeout=) + mock_http.stubs(:read_timeout=) + mock_http.stubs(:request).yields(mock_response) + + captured_args = nil + Net::HTTP.stubs(:new).with do |*args| + captured_args = args + true + end.returns(mock_http) + + client = Assistant::External::Client.new( + url: "http://agent.internal.example.com:18789/v1/chat", + token: "test-token" + ) + + ClimateControl.modify(HTTP_PROXY: "http://proxy:8888", NO_PROXY: "localhost,.example.com") do + client.chat(messages: [ { role: "user", content: "test" } ]) { |_| } + end + + # Should NOT pass proxy args — only host and port + assert_equal 2, captured_args.length + end + + private + + def mock_http_streaming_response(sse_body) + capture = [] + mock_response = stub("response") + mock_response.stubs(:code).returns("200") + mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true) + mock_response.stubs(:read_body).yields(sse_body) + + mock_http = stub("http") + mock_http.stubs(:use_ssl=) + mock_http.stubs(:open_timeout=) + mock_http.stubs(:read_timeout=) + mock_http.stubs(:request).with do |req| + capture[0] = req + true + end.yields(mock_response) + + Net::HTTP.stubs(:new).returns(mock_http) + capture + end + + def mock_http_streaming_response_chunked(chunks) + mock_response = stub("response") + mock_response.stubs(:code).returns("200") + mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true) + mock_response.stubs(:read_body).multiple_yields(*chunks.map { |c| [ c ] }) + + mock_http = stub("http") + mock_http.stubs(:use_ssl=) + mock_http.stubs(:open_timeout=) + mock_http.stubs(:read_timeout=) + mock_http.stubs(:request).yields(mock_response) + + Net::HTTP.stubs(:new).returns(mock_http) + end + + def mock_http_error_response(code, message) + mock_response = stub("response") + mock_response.stubs(:code).returns(code.to_s) + mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(false) + mock_response.stubs(:body).returns(message) + + mock_http = stub("http") + mock_http.stubs(:use_ssl=) + mock_http.stubs(:open_timeout=) + mock_http.stubs(:read_timeout=) + mock_http.stubs(:request).yields(mock_response) + + Net::HTTP.stubs(:new).returns(mock_http) + end +end diff --git a/test/models/assistant/external_config_test.rb b/test/models/assistant/external_config_test.rb new file mode 100644 index 000000000..77f2a342d --- /dev/null +++ b/test/models/assistant/external_config_test.rb @@ -0,0 +1,93 @@ +require "test_helper" + +class Assistant::ExternalConfigTest < ActiveSupport::TestCase + test "config reads URL from environment with priority over Setting" do + with_env_overrides("EXTERNAL_ASSISTANT_URL" => "http://from-env/v1/chat") do + assert_equal "http://from-env/v1/chat", Assistant::External.config.url + assert_equal "main", Assistant::External.config.agent_id + assert_equal "agent:main:main", Assistant::External.config.session_key + end + end + + test "config falls back to Setting when env var is absent" do + Setting.external_assistant_url = "http://from-setting/v1/chat" + Setting.external_assistant_token = "setting-token" + + with_env_overrides("EXTERNAL_ASSISTANT_URL" => nil, "EXTERNAL_ASSISTANT_TOKEN" => nil) do + assert_equal "http://from-setting/v1/chat", Assistant::External.config.url + assert_equal "setting-token", Assistant::External.config.token + end + ensure + Setting.external_assistant_url = nil + Setting.external_assistant_token = nil + end + + test "config reads agent_id with custom value" do + with_env_overrides( + "EXTERNAL_ASSISTANT_URL" => "http://example.com/v1/chat", + "EXTERNAL_ASSISTANT_TOKEN" => "test-token", + "EXTERNAL_ASSISTANT_AGENT_ID" => "finance-bot" + ) do + assert_equal "finance-bot", Assistant::External.config.agent_id + assert_equal "test-token", Assistant::External.config.token + end + end + + test "config reads session_key with custom value" do + with_env_overrides( + "EXTERNAL_ASSISTANT_URL" => "http://example.com/v1/chat", + "EXTERNAL_ASSISTANT_TOKEN" => "test-token", + "EXTERNAL_ASSISTANT_SESSION_KEY" => "agent:finance-bot:finance" + ) do + assert_equal "agent:finance-bot:finance", Assistant::External.config.session_key + end + end + + test "available_for? allows any user when no allowlist is set" do + user = OpenStruct.new(email: "anyone@example.com") + with_env_overrides("EXTERNAL_ASSISTANT_URL" => "http://x", "EXTERNAL_ASSISTANT_TOKEN" => "t", "EXTERNAL_ASSISTANT_ALLOWED_EMAILS" => nil) do + assert Assistant::External.available_for?(user) + end + end + + test "available_for? restricts to allowlisted emails" do + allowed = OpenStruct.new(email: "josh@example.com") + denied = OpenStruct.new(email: "other@example.com") + with_env_overrides("EXTERNAL_ASSISTANT_URL" => "http://x", "EXTERNAL_ASSISTANT_TOKEN" => "t", "EXTERNAL_ASSISTANT_ALLOWED_EMAILS" => "josh@example.com, admin@example.com") do + assert Assistant::External.available_for?(allowed) + assert_not Assistant::External.available_for?(denied) + end + end + + test "build_conversation_messages truncates to last 20 messages" do + chat = chats(:one) + + # Create enough messages to exceed the 20-message cap + 25.times do |i| + role_class = i.even? ? UserMessage : AssistantMessage + role_class.create!(chat: chat, content: "msg #{i}", ai_model: "test") + end + + with_env_overrides("EXTERNAL_ASSISTANT_URL" => "http://x", "EXTERNAL_ASSISTANT_TOKEN" => "t") do + external = Assistant::External.new(chat) + messages = external.send(:build_conversation_messages) + + assert_equal 20, messages.length + # Last message should be the most recent one we created + assert_equal "msg 24", messages.last[:content] + end + end + + test "configured? returns true only when URL and token are both present" do + Setting.external_assistant_url = nil + Setting.external_assistant_token = nil + + with_env_overrides("EXTERNAL_ASSISTANT_URL" => "http://x", "EXTERNAL_ASSISTANT_TOKEN" => nil) do + assert_not Assistant::External.configured? + end + + with_env_overrides("EXTERNAL_ASSISTANT_URL" => "http://x", "EXTERNAL_ASSISTANT_TOKEN" => "t") do + assert Assistant::External.configured? + end + end +end diff --git a/test/models/assistant_test.rb b/test/models/assistant_test.rb index 7ced43542..b396cf7ed 100644 --- a/test/models/assistant_test.rb +++ b/test/models/assistant_test.rb @@ -187,14 +187,231 @@ class AssistantTest < ActiveSupport::TestCase test "for_chat returns External when family assistant_type is external" do @chat.user.family.update!(assistant_type: "external") - assistant = Assistant.for_chat(@chat) - assert_instance_of Assistant::External, assistant - assert_no_difference "AssistantMessage.count" do - assistant.respond_to(@message) + assert_instance_of Assistant::External, Assistant.for_chat(@chat) + end + + test "ASSISTANT_TYPE env override forces external regardless of DB value" do + assert_equal "builtin", @chat.user.family.assistant_type + + with_env_overrides("ASSISTANT_TYPE" => "external") do + assert_instance_of Assistant::External, Assistant.for_chat(@chat) + end + + assert_instance_of Assistant::Builtin, Assistant.for_chat(@chat) + end + + test "external assistant responds with streamed text" do + @chat.user.family.update!(assistant_type: "external") + assistant = Assistant.for_chat(@chat) + + sse_body = <<~SSE + data: {"choices":[{"delta":{"content":"Your net worth"}}],"model":"ext-agent:main"} + + data: {"choices":[{"delta":{"content":" is $124,200."}}],"model":"ext-agent:main"} + + data: [DONE] + + SSE + + mock_external_sse_response(sse_body) + + with_env_overrides( + "EXTERNAL_ASSISTANT_URL" => "http://localhost:18789/v1/chat", + "EXTERNAL_ASSISTANT_TOKEN" => "test-token" + ) do + assert_difference "AssistantMessage.count", 1 do + assistant.respond_to(@message) + end + + response_msg = @chat.messages.where(type: "AssistantMessage").last + assert_equal "Your net worth is $124,200.", response_msg.content + assert_equal "ext-agent:main", response_msg.ai_model + end + end + + test "external assistant adds error when not configured" do + @chat.user.family.update!(assistant_type: "external") + assistant = Assistant.for_chat(@chat) + + with_env_overrides( + "EXTERNAL_ASSISTANT_URL" => nil, + "EXTERNAL_ASSISTANT_TOKEN" => nil + ) do + # Ensure Settings are also cleared to avoid test pollution from + # other tests that may have set these values in the same process. + Setting.external_assistant_url = nil + Setting.external_assistant_token = nil + Setting.clear_cache + + assert_no_difference "AssistantMessage.count" do + assistant.respond_to(@message) + end + + @chat.reload + assert @chat.error.present? + assert_includes @chat.error, "not configured" + end + ensure + Setting.external_assistant_url = nil + Setting.external_assistant_token = nil + end + + test "external assistant adds error on connection failure" do + @chat.user.family.update!(assistant_type: "external") + assistant = Assistant.for_chat(@chat) + + Net::HTTP.any_instance.stubs(:request).raises(Errno::ECONNREFUSED, "Connection refused") + + with_env_overrides( + "EXTERNAL_ASSISTANT_URL" => "http://localhost:18789/v1/chat", + "EXTERNAL_ASSISTANT_TOKEN" => "test-token" + ) do + assert_no_difference "AssistantMessage.count" do + assistant.respond_to(@message) + end + + @chat.reload + assert @chat.error.present? + end + end + + test "external assistant handles empty response gracefully" do + @chat.user.family.update!(assistant_type: "external") + assistant = Assistant.for_chat(@chat) + + sse_body = <<~SSE + data: {"choices":[{"delta":{"role":"assistant"}}],"model":"ext-agent:main"} + + data: {"choices":[{"delta":{}}],"model":"ext-agent:main"} + + data: [DONE] + + SSE + + mock_external_sse_response(sse_body) + + with_env_overrides( + "EXTERNAL_ASSISTANT_URL" => "http://localhost:18789/v1/chat", + "EXTERNAL_ASSISTANT_TOKEN" => "test-token" + ) do + assert_no_difference "AssistantMessage.count" do + assistant.respond_to(@message) + end + + @chat.reload + assert @chat.error.present? + assert_includes @chat.error, "empty response" + end + end + + test "external assistant sends conversation history" do + @chat.user.family.update!(assistant_type: "external") + assistant = Assistant.for_chat(@chat) + + AssistantMessage.create!(chat: @chat, content: "I can help with that.", ai_model: "external") + + sse_body = "data: {\"choices\":[{\"delta\":{\"content\":\"Sure!\"}}],\"model\":\"m\"}\n\ndata: [DONE]\n\n" + capture = mock_external_sse_response(sse_body) + + with_env_overrides( + "EXTERNAL_ASSISTANT_URL" => "http://localhost:18789/v1/chat", + "EXTERNAL_ASSISTANT_TOKEN" => "test-token" + ) do + assistant.respond_to(@message) + + body = JSON.parse(capture[0].body) + messages = body["messages"] + + assert messages.size >= 2 + assert_equal "user", messages.first["role"] + end + end + + test "full external assistant flow: config check, stream, save, error recovery" do + @chat.user.family.update!(assistant_type: "external") + + # Phase 1: Without config, errors gracefully + with_env_overrides("EXTERNAL_ASSISTANT_URL" => nil, "EXTERNAL_ASSISTANT_TOKEN" => nil) do + Setting.external_assistant_url = nil + Setting.external_assistant_token = nil + Setting.clear_cache + + assistant = Assistant::External.new(@chat) + assistant.respond_to(@message) + @chat.reload + assert @chat.error.present? + end + + # Phase 2: With config, streams response + @chat.update!(error: nil) + + sse_body = <<~SSE + data: {"choices":[{"delta":{"content":"Based on your accounts, "}}],"model":"ext-agent:main"} + + data: {"choices":[{"delta":{"content":"your net worth is $50,000."}}],"model":"ext-agent:main"} + + data: [DONE] + + SSE + + mock_external_sse_response(sse_body) + + with_env_overrides( + "EXTERNAL_ASSISTANT_URL" => "http://localhost:18789/v1/chat", + "EXTERNAL_ASSISTANT_TOKEN" => "test-token" + ) do + assistant = Assistant::External.new(@chat) + assistant.respond_to(@message) + + @chat.reload + assert_nil @chat.error + + response = @chat.messages.where(type: "AssistantMessage").last + assert_equal "Based on your accounts, your net worth is $50,000.", response.content + assert_equal "ext-agent:main", response.ai_model + end + end + + test "ASSISTANT_TYPE env override with unknown value falls back to builtin" do + with_env_overrides("ASSISTANT_TYPE" => "nonexistent") do + assert_instance_of Assistant::Builtin, Assistant.for_chat(@chat) + end + end + + test "external assistant sets user identifier with family_id" do + @chat.user.family.update!(assistant_type: "external") + assistant = Assistant.for_chat(@chat) + + sse_body = "data: {\"choices\":[{\"delta\":{\"content\":\"OK\"}}],\"model\":\"m\"}\n\ndata: [DONE]\n\n" + capture = mock_external_sse_response(sse_body) + + with_env_overrides( + "EXTERNAL_ASSISTANT_URL" => "http://localhost:18789/v1/chat", + "EXTERNAL_ASSISTANT_TOKEN" => "test-token" + ) do + assistant.respond_to(@message) + + body = JSON.parse(capture[0].body) + assert_equal "sure-family-#{@chat.user.family_id}", body["user"] + end + end + + test "external assistant updates ai_model from SSE response model field" do + @chat.user.family.update!(assistant_type: "external") + assistant = Assistant.for_chat(@chat) + + sse_body = "data: {\"choices\":[{\"delta\":{\"content\":\"Hi\"}}],\"model\":\"ext-agent:custom\"}\n\ndata: [DONE]\n\n" + mock_external_sse_response(sse_body) + + with_env_overrides( + "EXTERNAL_ASSISTANT_URL" => "http://localhost:18789/v1/chat", + "EXTERNAL_ASSISTANT_TOKEN" => "test-token" + ) do + assistant.respond_to(@message) + + response = @chat.messages.where(type: "AssistantMessage").last + assert_equal "ext-agent:custom", response.ai_model end - @chat.reload - assert @chat.error.present? - assert_includes @chat.error, "not yet implemented" end test "for_chat raises when chat is blank" do @@ -202,6 +419,27 @@ class AssistantTest < ActiveSupport::TestCase end private + + def mock_external_sse_response(sse_body) + capture = [] + mock_response = stub("response") + mock_response.stubs(:code).returns("200") + mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true) + mock_response.stubs(:read_body).yields(sse_body) + + mock_http = stub("http") + mock_http.stubs(:use_ssl=) + mock_http.stubs(:open_timeout=) + mock_http.stubs(:read_timeout=) + mock_http.stubs(:request).with do |req| + capture[0] = req + true + end.yields(mock_response) + + Net::HTTP.stubs(:new).returns(mock_http) + capture + end + def provider_function_request(id:, call_id:, function_name:, function_args:) Provider::LlmConcept::ChatFunctionRequest.new( id: id, diff --git a/test/models/balance/forward_calculator_test.rb b/test/models/balance/forward_calculator_test.rb index b2462cb58..3d2c9fa26 100644 --- a/test/models/balance/forward_calculator_test.rb +++ b/test/models/balance/forward_calculator_test.rb @@ -581,6 +581,180 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase ) end + # ------------------------------------------------------------------------------------------------ + # Incremental calculation (window_start_date) + # ------------------------------------------------------------------------------------------------ + + test "incremental sync produces same results as full sync for the recalculated window" do + account = create_account_with_ledger( + account: { type: Depository, currency: "USD" }, + entries: [ + { type: "opening_anchor", date: 5.days.ago.to_date, balance: 20000 }, + { type: "transaction", date: 4.days.ago.to_date, amount: -500 }, # income → 20500 + { type: "transaction", date: 2.days.ago.to_date, amount: 100 } # expense → 20400 + ] + ) + + # Persist full balances via the materializer (same path as production). + Balance::Materializer.new(account, strategy: :forward).materialize_balances + + # Incremental from 3.days.ago: seeds from persisted balance on 4.days.ago (20500). + incremental = Balance::ForwardCalculator.new(account, window_start_date: 3.days.ago.to_date).calculate + + assert_equal [ 3.days.ago.to_date, 2.days.ago.to_date ], incremental.map(&:date).sort + + assert_calculated_ledger_balances( + calculated_data: incremental, + expected_data: [ + { + date: 3.days.ago.to_date, + legacy_balances: { balance: 20500, cash_balance: 20500 }, + balances: { start: 20500, start_cash: 20500, start_non_cash: 0, end_cash: 20500, end_non_cash: 0, end: 20500 }, + flows: 0, + adjustments: 0 + }, + { + date: 2.days.ago.to_date, + legacy_balances: { balance: 20400, cash_balance: 20400 }, + balances: { start: 20500, start_cash: 20500, start_non_cash: 0, end_cash: 20400, end_non_cash: 0, end: 20400 }, + flows: { cash_inflows: 0, cash_outflows: 100 }, + adjustments: 0 + } + ] + ) + end + + test "falls back to full recalculation when prior balance has a non-cash component" do + account = create_account_with_ledger( + account: { type: Depository, currency: "USD" }, + entries: [ + { type: "opening_anchor", date: 3.days.ago.to_date, balance: 20000 }, + { type: "transaction", date: 2.days.ago.to_date, amount: -500 } + ] + ) + + # Persist a prior balance (window_start_date - 1 = 3.days.ago) with a non-zero + # non-cash component. This simulates an investment account where holdings were + # fully recalculated, making the stored non-cash seed potentially stale. + account.balances.create!( + date: 3.days.ago.to_date, + balance: 20000, + cash_balance: 15000, + currency: "USD", + start_cash_balance: 15000, + start_non_cash_balance: 5000, + cash_inflows: 0, cash_outflows: 0, + non_cash_inflows: 0, non_cash_outflows: 0, + net_market_flows: 0, cash_adjustments: 0, non_cash_adjustments: 0, + flows_factor: 1 + ) + + result = Balance::ForwardCalculator.new(account, window_start_date: 2.days.ago.to_date).calculate + + # Fell back: full range from opening_anchor_date, not just the window. + assert_includes result.map(&:date), 3.days.ago.to_date + assert_includes result.map(&:date), 2.days.ago.to_date + end + + test "falls back to full recalculation when no prior balance exists in DB" do + account = create_account_with_ledger( + account: { type: Depository, currency: "USD" }, + entries: [ + { type: "opening_anchor", date: 3.days.ago.to_date, balance: 20000 }, + { type: "transaction", date: 2.days.ago.to_date, amount: -500 } + ] + ) + + # No persisted balances — prior_balance will be nil, so fall back to full sync. + result = Balance::ForwardCalculator.new(account, window_start_date: 2.days.ago.to_date).calculate + + # Full range returned (opening_anchor_date to last entry date). + assert_equal [ 3.days.ago.to_date, 2.days.ago.to_date ], result.map(&:date).sort + + assert_calculated_ledger_balances( + calculated_data: result, + expected_data: [ + { + date: 3.days.ago.to_date, + legacy_balances: { balance: 20000, cash_balance: 20000 }, + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, + flows: 0, + adjustments: 0 + }, + { + date: 2.days.ago.to_date, + legacy_balances: { balance: 20500, cash_balance: 20500 }, + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20500, end_non_cash: 0, end: 20500 }, + flows: { cash_inflows: 500, cash_outflows: 0 }, + adjustments: 0 + } + ] + ) + end + + test "multi-currency account falls back to full recalc so late exchange rate imports are picked up" do + # Step 1: Create account with a EUR entry but NO exchange rate yet. + # SyncCache will use fallback_rate: 1, so the €500 entry is treated as $500. + account = create_account_with_ledger( + account: { type: Depository, currency: "USD" }, + entries: [ + { type: "opening_anchor", date: 4.days.ago.to_date, balance: 100 }, + { type: "transaction", date: 3.days.ago.to_date, amount: -100 }, + { type: "transaction", date: 2.days.ago.to_date, amount: -500, currency: "EUR" } + ] + ) + + # First full sync — balances computed with fallback rate (1:1 EUR→USD). + Balance::Materializer.new(account, strategy: :forward).materialize_balances + stale_balance = account.balances.find_by(date: 2.days.ago.to_date) + assert stale_balance, "Balance should exist after full sync" + + # Step 2: Exchange rate arrives later (e.g. daily cron imports it). + ExchangeRate.create!(date: 2.days.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: 1.2) + + # Step 3: Next sync requests incremental from today — but the guard should + # force a full recalc because the account has multi-currency entries. + calculator = Balance::ForwardCalculator.new(account, window_start_date: 1.day.ago.to_date) + result = calculator.calculate + + assert_not calculator.incremental?, "Should not be incremental for multi-currency accounts" + + # Full range returned — includes dates before the window. + assert_includes result.map(&:date), 4.days.ago.to_date + + # The EUR entry on 2.days.ago is now converted at 1.2, so the balance + # picks up the corrected rate: opening 100 + $100 txn + €500*1.2 = $800 + # (without the guard, incremental mode would have seeded from the stale + # $700 balance computed with fallback_rate 1, and never corrected it). + corrected = result.find { |b| b.date == 2.days.ago.to_date } + assert corrected + assert_equal 800, corrected.balance, + "Balance should reflect the corrected EUR→USD rate (€500 * 1.2 = $600, not $500)" + end + + test "falls back to full recalculation for foreign accounts (account currency != family currency)" do + account = create_account_with_ledger( + account: { type: Depository, currency: "EUR" }, + entries: [ + { type: "opening_anchor", date: 3.days.ago.to_date, balance: 1000 }, + { type: "transaction", date: 2.days.ago.to_date, amount: -100 } + ] + ) + + # Precondition: account currency must differ from family currency for this test. + assert_not_equal account.currency, account.family.currency, + "Test requires account currency (#{account.currency}) to differ from family currency (#{account.family.currency})" + + # Persist balances via full materializer. + Balance::Materializer.new(account, strategy: :forward).materialize_balances + calculator = Balance::ForwardCalculator.new(account, window_start_date: 2.days.ago.to_date) + result = calculator.calculate + + # Full range returned. + assert_includes result.map(&:date), 3.days.ago.to_date + assert_not calculator.incremental?, "Should not be incremental for foreign currency accounts" + end + private def assert_balances(calculated_data:, expected_balances:) # Sort calculated data by date to ensure consistent ordering diff --git a/test/models/balance/materializer_test.rb b/test/models/balance/materializer_test.rb index 01d347694..472f5fbd5 100644 --- a/test/models/balance/materializer_test.rb +++ b/test/models/balance/materializer_test.rb @@ -61,6 +61,100 @@ class Balance::MaterializerTest < ActiveSupport::TestCase assert_balance_fields_persisted(expected_balances) end + test "incremental sync preserves balances before window_start_date and purges only beyond calc_end_date" do + # Add an opening anchor so opening_anchor_date is well in the past. + @account.entries.create!( + name: "Opening Balance", + date: 10.days.ago.to_date, + amount: 5000, + currency: "USD", + entryable: Valuation.new(kind: "opening_anchor") + ) + + preserved_old = create_balance(account: @account, date: 5.days.ago.to_date, balance: 10000) + preserved_mid = create_balance(account: @account, date: 3.days.ago.to_date, balance: 12000) + stale_future = create_balance(account: @account, date: 5.days.from_now.to_date, balance: 99000) + + # Calculator returns only the window being recalculated (2.days.ago). + recalculated = [ + Balance.new( + date: 2.days.ago.to_date, + balance: 15000, + cash_balance: 15000, + currency: "USD", + start_cash_balance: 12000, + start_non_cash_balance: 0, + cash_inflows: 3000, + cash_outflows: 0, + non_cash_inflows: 0, + non_cash_outflows: 0, + net_market_flows: 0, + cash_adjustments: 0, + non_cash_adjustments: 0, + flows_factor: 1 + ) + ] + + Balance::ForwardCalculator.any_instance.expects(:calculate).returns(recalculated) + Balance::ForwardCalculator.any_instance.stubs(:incremental?).returns(true) + Holding::Materializer.any_instance.expects(:materialize_holdings).returns([]).once + + Balance::Materializer.new(@account, strategy: :forward, window_start_date: 2.days.ago.to_date).materialize_balances + + # Balances before window_start_date must be preserved. + assert_not_nil @account.balances.find_by(id: preserved_old.id), + "Balance at 5.days.ago should be preserved (before window_start_date)" + assert_not_nil @account.balances.find_by(id: preserved_mid.id), + "Balance at 3.days.ago should be preserved (before window_start_date)" + + # Balance after calc_end_date must be purged. + assert_nil @account.balances.find_by(id: stale_future.id), + "Balance at 5.days.from_now should be purged (after calc_end_date)" + + # Recalculated balance must be present. + assert_not_nil @account.balances.find_by(date: 2.days.ago.to_date), + "Recalculated balance for 2.days.ago should be persisted" + end + + test "falls back to full recalculation when window_start_date is given but no prior balance exists" do + @account.entries.create!( + name: "Opening Balance", + date: 5.days.ago.to_date, + amount: 20000, + currency: "USD", + entryable: Valuation.new(kind: "opening_anchor") + ) + @account.entries.create!( + name: "Test transaction", + date: 3.days.ago.to_date, + amount: -1000, + currency: "USD", + entryable: Transaction.new + ) + + # A stale pre-window balance with a wrong value. + # In successful incremental mode this would be preserved as-is; + # in fallback (no prior balance) the full recalc must overwrite it. + wrong_pre_window = create_balance(account: @account, date: 4.days.ago.to_date, balance: 99999) + + # A stale balance before opening_anchor_date — must be purged in both modes. + stale_before_anchor = create_balance(account: @account, date: 8.days.ago.to_date, balance: 99999) + + Holding::Materializer.any_instance.stubs(:materialize_holdings).returns([]) + + # No prior balance exists for window_start_date - 1 (3.days.ago) → calculator falls back to full recalc. + Balance::Materializer.new(@account, strategy: :forward, window_start_date: 2.days.ago.to_date).materialize_balances + + # After fallback the pre-window balance must be recalculated with the correct value, not preserved. + recalculated = @account.balances.find_by(date: wrong_pre_window.date) + assert_not_nil recalculated, "Balance at 4.days.ago should exist after full recalculation" + assert_equal 20000, recalculated.balance, "Balance should reflect full recalculation, not the stale value (99999)" + + # Stale balance before opening_anchor_date should be purged. + assert_nil @account.balances.find_by(id: stale_before_anchor.id), + "Balance before opening_anchor_date should be purged" + end + test "purges stale balances outside calculated range" do # Create existing balances that will be stale stale_old = create_balance(account: @account, date: 5.days.ago.to_date, balance: 5000) diff --git a/test/models/binance_account/holdings_processor_test.rb b/test/models/binance_account/holdings_processor_test.rb new file mode 100644 index 000000000..929fccf52 --- /dev/null +++ b/test/models/binance_account/holdings_processor_test.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require "test_helper" + +class BinanceAccount::HoldingsProcessorTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + @family.update!(currency: "EUR") + + @item = BinanceItem.create!( + family: @family, name: "Binance", api_key: "k", api_secret: "s" + ) + @ba = @item.binance_accounts.create!( + name: "Binance", + account_type: "combined", + currency: "USD", + current_balance: 1000, + raw_payload: { + "assets" => [ { "symbol" => "BTC", "total" => "0.5", "source" => "spot" } ] + } + ) + @account = Account.create!( + family: @family, + name: "Binance", + balance: 0, + currency: "EUR", + accountable: Crypto.create!(subtype: "exchange") + ) + AccountProvider.create!(account: @account, provider: @ba) + end + + test "converts holding amount to family currency when exact rate exists" do + ExchangeRate.create!(from_currency: "USD", to_currency: "EUR", + date: Date.current, rate: 0.92) + + Security.find_or_create_by!(ticker: "CRYPTO:BTC") do |s| + s.name = "BTC" + s.exchange_operating_mic = "XBNC" + end + + BinanceAccount::HoldingsProcessor.any_instance + .stubs(:fetch_price).with("BTC").returns(60_000.0) + + import_adapter = mock + import_adapter.expects(:import_holding).with( + has_entries(currency: "EUR", amount: 27_600.0) + ) + Account::ProviderImportAdapter.stubs(:new).returns(import_adapter) + + BinanceAccount::HoldingsProcessor.new(@ba).process + end + + test "uses raw USD amount when no rate is available" do + ExchangeRate.stubs(:find_or_fetch_rate).returns(nil) + + Security.find_or_create_by!(ticker: "CRYPTO:BTC") do |s| + s.name = "BTC" + s.exchange_operating_mic = "XBNC" + end + + BinanceAccount::HoldingsProcessor.any_instance + .stubs(:fetch_price).with("BTC").returns(60_000.0) + + import_adapter = mock + import_adapter.expects(:import_holding).with( + has_entries(currency: "EUR", amount: 30_000.0) + ) + Account::ProviderImportAdapter.stubs(:new).returns(import_adapter) + + BinanceAccount::HoldingsProcessor.new(@ba).process + end +end diff --git a/test/models/binance_account/processor_test.rb b/test/models/binance_account/processor_test.rb new file mode 100644 index 000000000..9a08ab158 --- /dev/null +++ b/test/models/binance_account/processor_test.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require "test_helper" + +class BinanceAccount::ProcessorTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + @family.update!(currency: "EUR") + + @item = BinanceItem.create!( + family: @family, name: "Binance", api_key: "k", api_secret: "s" + ) + @ba = @item.binance_accounts.create!( + name: "Binance", account_type: "combined", currency: "USD", current_balance: 1000 + ) + @account = Account.create!( + family: @family, + name: "Binance", + balance: 0, + currency: "EUR", + accountable: Crypto.create!(subtype: "exchange") + ) + AccountProvider.create!(account: @account, provider: @ba) + + BinanceAccount::HoldingsProcessor.any_instance.stubs(:process).returns(nil) + @ba.stubs(:binance_item).returns( + stub(binance_provider: nil, family: @family) + ) + end + + test "converts USD balance to family currency when exact rate exists" do + ExchangeRate.create!(from_currency: "USD", to_currency: "EUR", + date: Date.current, rate: 0.92) + + BinanceAccount::Processor.new(@ba).process + + @account.reload + @ba.reload + assert_equal "EUR", @account.currency + assert_in_delta 920.0, @account.balance, 0.01 + assert_equal false, @ba.extra.dig("binance", "stale_rate") + end + + test "uses nearest rate and sets stale flag when exact rate missing" do + ExchangeRate.create!(from_currency: "USD", to_currency: "EUR", + date: Date.current - 3, rate: 0.90) + + BinanceAccount::Processor.new(@ba).process + + @account.reload + @ba.reload + assert_equal "EUR", @account.currency + assert_in_delta 900.0, @account.balance, 0.01 + assert_equal true, @ba.extra.dig("binance", "stale_rate") + end + + test "falls back to USD amount and sets stale flag when no rate available" do + ExchangeRate.expects(:find_or_fetch_rate).returns(nil) + + BinanceAccount::Processor.new(@ba).process + + @account.reload + @ba.reload + assert_in_delta 1000.0, @account.balance, 0.01 + assert_equal true, @ba.extra.dig("binance", "stale_rate") + end + + test "clears stale flag on subsequent sync when exact rate found" do + @ba.update!(extra: { "binance" => { "stale_rate" => true } }) + ExchangeRate.create!(from_currency: "USD", to_currency: "EUR", + date: Date.current, rate: 0.92) + + BinanceAccount::Processor.new(@ba).process + + @account.reload + @ba.reload + assert_equal false, @ba.extra.dig("binance", "stale_rate") + end + + test "does not convert when family uses USD" do + @family.update!(currency: "USD") + + BinanceAccount::Processor.new(@ba).process + + @account.reload + assert_equal "USD", @account.currency + assert_in_delta 1000.0, @account.balance, 0.01 + end +end diff --git a/test/models/binance_account/usd_converter_test.rb b/test/models/binance_account/usd_converter_test.rb new file mode 100644 index 000000000..333056375 --- /dev/null +++ b/test/models/binance_account/usd_converter_test.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require "test_helper" + +class BinanceAccount::UsdConverterTest < ActiveSupport::TestCase + # A minimal host class that includes the concern so we can test it in isolation + class Host + include BinanceAccount::UsdConverter + + def initialize(family_currency) + @family_currency = family_currency + end + + def target_currency + @family_currency + end + end + + test "returns original amount unchanged when target is USD" do + host = Host.new("USD") + amount, stale, rate_date = host.send(:convert_from_usd, 1000.0, date: Date.current) + assert_equal 1000.0, amount + assert_equal false, stale + assert_nil rate_date + end + + test "returns converted amount when exact rate exists" do + date = Date.new(2026, 3, 28) + ExchangeRate.create!(from_currency: "USD", to_currency: "EUR", date: date, rate: 0.92) + + host = Host.new("EUR") + amount, stale, rate_date = host.send(:convert_from_usd, 1000.0, date: date) + + assert_in_delta 920.0, amount, 0.01 + assert_equal false, stale + assert_nil rate_date + end + + test "marks stale and returns converted amount when nearest rate used" do + old_date = Date.new(2026, 3, 25) + ExchangeRate.create!(from_currency: "USD", to_currency: "EUR", date: old_date, rate: 0.91) + + host = Host.new("EUR") + amount, stale, rate_date = host.send(:convert_from_usd, 1000.0, date: Date.new(2026, 3, 28)) + + assert_in_delta 910.0, amount, 0.01 + assert_equal true, stale + assert_equal old_date, rate_date + end + + test "returns raw USD amount with stale flag when no rate available" do + host = Host.new("EUR") + ExchangeRate.expects(:find_or_fetch_rate).returns(nil) + + amount, stale, rate_date = host.send(:convert_from_usd, 1000.0, date: Date.new(2026, 3, 28)) + + assert_equal 1000.0, amount + assert_equal true, stale + assert_nil rate_date + end + + test "build_stale_extra returns correct hash when stale" do + host = Host.new("EUR") + result = host.send(:build_stale_extra, true, Date.new(2026, 3, 25), Date.new(2026, 3, 28)) + + assert_equal({ "binance" => { "stale_rate" => true, "rate_date_used" => "2026-03-25", "rate_target_date" => "2026-03-28" } }, result) + end + + test "build_stale_extra returns cleared hash when not stale" do + host = Host.new("EUR") + result = host.send(:build_stale_extra, false, nil, Date.new(2026, 3, 28)) + + assert_equal({ "binance" => { "stale_rate" => false } }, result) + end +end diff --git a/test/models/binance_account_test.rb b/test/models/binance_account_test.rb new file mode 100644 index 000000000..2883c3e8a --- /dev/null +++ b/test/models/binance_account_test.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "test_helper" + +class BinanceAccountTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + @item = binance_items(:one) + @ba = binance_accounts(:one) + end + + test "belongs to binance_item" do + assert_equal @item, @ba.binance_item + end + + test "validates presence of name" do + ba = @item.binance_accounts.build(account_type: "combined", currency: "USD") + assert_not ba.valid? + assert_includes ba.errors[:name], "can't be blank" + end + + test "validates presence of currency" do + ba = @item.binance_accounts.build(name: "Binance", account_type: "combined") + assert_not ba.valid? + assert_includes ba.errors[:currency], "can't be blank" + end + + test "ensure_account_provider! creates AccountProvider" do + account = Account.create!( + family: @family, name: "Binance", balance: 0, currency: "USD", + accountable: Crypto.create!(subtype: "exchange") + ) + + @ba.ensure_account_provider!(account) + + ap = AccountProvider.find_by(provider: @ba) + assert_not_nil ap + assert_equal account, ap.account + end + + test "ensure_account_provider! is idempotent" do + account = Account.create!( + family: @family, name: "Binance", balance: 0, currency: "USD", + accountable: Crypto.create!(subtype: "exchange") + ) + + @ba.ensure_account_provider!(account) + @ba.ensure_account_provider!(account) + + assert_equal 1, AccountProvider.where(provider: @ba).count + end + + test "current_account returns linked account" do + assert_nil @ba.current_account + + account = Account.create!( + family: @family, name: "Binance", balance: 0, currency: "USD", + accountable: Crypto.create!(subtype: "exchange") + ) + AccountProvider.create!(account: account, provider: @ba) + + assert_equal account, @ba.reload.current_account + end +end diff --git a/test/models/binance_item/earn_importer_test.rb b/test/models/binance_item/earn_importer_test.rb new file mode 100644 index 000000000..c0797ad83 --- /dev/null +++ b/test/models/binance_item/earn_importer_test.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require "test_helper" + +class BinanceItem::EarnImporterTest < ActiveSupport::TestCase + setup do + @provider = mock + @family = families(:dylan_family) + @item = BinanceItem.create!(family: @family, name: "B", api_key: "k", api_secret: "s") + end + + test "merges flexible and locked positions with source=earn" do + @provider.stubs(:get_simple_earn_flexible).returns({ + "rows" => [ { "asset" => "USDT", "totalAmount" => "500.0" } ] + }) + @provider.stubs(:get_simple_earn_locked).returns({ + "rows" => [ { "asset" => "BNB", "amount" => "10.0" } ] + }) + + result = BinanceItem::EarnImporter.new(@item, provider: @provider).import + + assert_equal "earn", result[:source] + assert_equal 2, result[:assets].size + usdt = result[:assets].find { |a| a[:symbol] == "USDT" } + assert_equal "500.0", usdt[:total] + assert_equal "500.0", usdt[:free] + assert_equal "0.0", usdt[:locked] + bnb = result[:assets].find { |a| a[:symbol] == "BNB" } + assert_equal "10.0", bnb[:total] + assert_equal "0.0", bnb[:free] + assert_equal "10.0", bnb[:locked] + end + + test "deduplicates assets from flexible and locked by summing" do + @provider.stubs(:get_simple_earn_flexible).returns({ + "rows" => [ { "asset" => "BTC", "totalAmount" => "1.0" } ] + }) + @provider.stubs(:get_simple_earn_locked).returns({ + "rows" => [ { "asset" => "BTC", "amount" => "0.5" } ] + }) + + result = BinanceItem::EarnImporter.new(@item, provider: @provider).import + + assert_equal 1, result[:assets].size + assert_equal "1.5", result[:assets].first[:total] + end + + test "returns empty assets when both APIs fail" do + @provider.stubs(:get_simple_earn_flexible).raises(Provider::Binance::ApiError, "error") + @provider.stubs(:get_simple_earn_locked).raises(Provider::Binance::ApiError, "error") + + result = BinanceItem::EarnImporter.new(@item, provider: @provider).import + + assert_equal "earn", result[:source] + assert_equal [], result[:assets] + assert_equal({ "flexible" => nil, "locked" => nil }, result[:raw]) + end +end diff --git a/test/models/binance_item/importer_test.rb b/test/models/binance_item/importer_test.rb new file mode 100644 index 000000000..0e9506155 --- /dev/null +++ b/test/models/binance_item/importer_test.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require "test_helper" + +class BinanceItem::ImporterTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + @item = BinanceItem.create!(family: @family, name: "B", api_key: "k", api_secret: "s") + @provider = mock + @provider.stubs(:get_spot_price).returns("50000.0") + + stub_spot_result([ { symbol: "BTC", free: "1.0", locked: "0.0", total: "1.0" } ]) + stub_margin_result([]) + stub_earn_result([]) + end + + test "creates a binance_account of type combined" do + assert_difference "@item.binance_accounts.count", 1 do + BinanceItem::Importer.new(@item, binance_provider: @provider).import + end + + ba = @item.binance_accounts.first + assert_equal "combined", ba.account_type + assert_equal "USD", ba.currency + end + + test "calculates combined USD balance" do + @provider.stubs(:get_spot_price).with("BTCUSDT").returns("50000.0") + + BinanceItem::Importer.new(@item, binance_provider: @provider).import + + ba = @item.binance_accounts.first + assert_in_delta 50000.0, ba.current_balance.to_f, 0.01 + end + + test "stablecoins counted at 1.0 without API call" do + stub_spot_result([ { symbol: "USDT", free: "1000.0", locked: "0.0", total: "1000.0" } ]) + + @provider.expects(:get_spot_price).never + + BinanceItem::Importer.new(@item, binance_provider: @provider).import + + ba = @item.binance_accounts.first + assert_in_delta 1000.0, ba.current_balance.to_f, 0.01 + end + + test "skips BinanceAccount creation when all sources empty" do + stub_spot_result([]) + stub_margin_result([]) + stub_earn_result([]) + + assert_no_difference "@item.binance_accounts.count" do + BinanceItem::Importer.new(@item, binance_provider: @provider).import + end + end + + test "stores source breakdown in raw_payload" do + BinanceItem::Importer.new(@item, binance_provider: @provider).import + + ba = @item.binance_accounts.first + assert ba.raw_payload.key?("spot") + assert ba.raw_payload.key?("margin") + assert ba.raw_payload.key?("earn") + end + + private + + def stub_spot_result(assets) + BinanceItem::SpotImporter.any_instance.stubs(:import).returns( + { assets: assets, raw: {}, source: "spot" } + ) + end + + def stub_margin_result(assets) + BinanceItem::MarginImporter.any_instance.stubs(:import).returns( + { assets: assets, raw: {}, source: "margin" } + ) + end + + def stub_earn_result(assets) + BinanceItem::EarnImporter.any_instance.stubs(:import).returns( + { assets: assets, raw: {}, source: "earn" } + ) + end +end diff --git a/test/models/binance_item/margin_importer_test.rb b/test/models/binance_item/margin_importer_test.rb new file mode 100644 index 000000000..58d5e2b83 --- /dev/null +++ b/test/models/binance_item/margin_importer_test.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "test_helper" + +class BinanceItem::MarginImporterTest < ActiveSupport::TestCase + setup do + @provider = mock + @family = families(:dylan_family) + @item = BinanceItem.create!(family: @family, name: "B", api_key: "k", api_secret: "s") + end + + test "returns normalized assets from userAssets with source=margin" do + @provider.stubs(:get_margin_account).returns({ + "userAssets" => [ + { "asset" => "BTC", "free" => "0.1", "locked" => "0.0", "netAsset" => "0.1" }, + { "asset" => "ETH", "free" => "0.0", "locked" => "0.0", "netAsset" => "0.0" } + ] + }) + + result = BinanceItem::MarginImporter.new(@item, provider: @provider).import + + assert_equal "margin", result[:source] + assert_equal 1, result[:assets].size + btc = result[:assets].first + assert_equal "BTC", btc[:symbol] + assert_equal "0.1", btc[:total] + end + + test "returns empty on API error" do + @provider.stubs(:get_margin_account).raises(Provider::Binance::ApiError, "WAF") + + result = BinanceItem::MarginImporter.new(@item, provider: @provider).import + + assert_equal "margin", result[:source] + assert_equal [], result[:assets] + end +end diff --git a/test/models/binance_item/spot_importer_test.rb b/test/models/binance_item/spot_importer_test.rb new file mode 100644 index 000000000..47520a9ed --- /dev/null +++ b/test/models/binance_item/spot_importer_test.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require "test_helper" + +class BinanceItem::SpotImporterTest < ActiveSupport::TestCase + setup do + @provider = mock + @family = families(:dylan_family) + @item = BinanceItem.create!(family: @family, name: "B", api_key: "k", api_secret: "s") + end + + test "returns normalized assets with source=spot" do + @provider.stubs(:get_spot_account).returns({ + "balances" => [ + { "asset" => "BTC", "free" => "1.5", "locked" => "0.5" }, + { "asset" => "ETH", "free" => "10.0", "locked" => "0.0" }, + { "asset" => "SHIB", "free" => "0.0", "locked" => "0.0" } + ] + }) + + result = BinanceItem::SpotImporter.new(@item, provider: @provider).import + + assert_equal "spot", result[:source] + assert_equal 2, result[:assets].size # SHIB filtered out (zero balance) + btc = result[:assets].find { |a| a[:symbol] == "BTC" } + assert_equal "1.5", btc[:free] + assert_equal "0.5", btc[:locked] + assert_equal "2.0", btc[:total] + end + + test "returns empty assets on API error" do + @provider.stubs(:get_spot_account).raises(Provider::Binance::AuthenticationError, "Invalid key") + + result = BinanceItem::SpotImporter.new(@item, provider: @provider).import + + assert_equal "spot", result[:source] + assert_equal [], result[:assets] + assert_nil result[:raw] + end + + test "filters out zero-balance assets" do + @provider.stubs(:get_spot_account).returns({ + "balances" => [ + { "asset" => "BTC", "free" => "0.0", "locked" => "0.0" }, + { "asset" => "ETH", "free" => "0.0", "locked" => "0.0" } + ] + }) + + result = BinanceItem::SpotImporter.new(@item, provider: @provider).import + + assert_equal [], result[:assets] + end +end diff --git a/test/models/binance_item_test.rb b/test/models/binance_item_test.rb new file mode 100644 index 000000000..c9a03bf31 --- /dev/null +++ b/test/models/binance_item_test.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require "test_helper" + +class BinanceItemTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + @item = BinanceItem.create!( + family: @family, + name: "My Binance", + api_key: "test_key", + api_secret: "test_secret" + ) + end + + test "belongs to family" do + assert_equal @family, @item.family + end + + test "has good status by default" do + assert_equal "good", @item.status + end + + test "validates presence of name" do + item = BinanceItem.new(family: @family, api_key: "k", api_secret: "s") + assert_not item.valid? + assert_includes item.errors[:name], "can't be blank" + end + + test "validates presence of api_key" do + item = BinanceItem.new(family: @family, name: "B", api_secret: "s") + assert_not item.valid? + assert_includes item.errors[:api_key], "can't be blank" + end + + test "validates presence of api_secret" do + item = BinanceItem.new(family: @family, name: "B", api_key: "k") + assert_not item.valid? + assert_includes item.errors[:api_secret], "can't be blank" + end + + test "active scope excludes scheduled for deletion" do + @item.update!(scheduled_for_deletion: true) + refute_includes BinanceItem.active.to_a, @item + end + + test "credentials_configured? returns true when both keys present" do + assert @item.credentials_configured? + end + + test "credentials_configured? returns false when api_key nil" do + @item.api_key = nil + refute @item.credentials_configured? + end + + test "destroy_later marks for deletion" do + @item.destroy_later + assert @item.scheduled_for_deletion? + end + + test "set_binance_institution_defaults! sets metadata" do + @item.set_binance_institution_defaults! + assert_equal "Binance", @item.institution_name + assert_equal "binance.com", @item.institution_domain + assert_equal "https://www.binance.com", @item.institution_url + assert_equal "#F0B90B", @item.institution_color + end + + test "sync_status_summary with no accounts" do + assert_equal I18n.t("binance_items.binance_item.sync_status.no_accounts"), @item.sync_status_summary + end + + test "sync_status_summary with all accounts linked" do + ba = @item.binance_accounts.create!(name: "Binance Combined", account_type: "combined", currency: "USD") + account = Account.create!( + family: @family, name: "Binance", balance: 0, currency: "USD", + accountable: Crypto.create!(subtype: "exchange") + ) + AccountProvider.create!(account: account, provider: ba) + + assert_equal I18n.t("binance_items.binance_item.sync_status.all_synced", count: 1), @item.sync_status_summary + end + + test "sync_status_summary with partial sync" do + # Linked account + ba1 = @item.binance_accounts.create!(name: "Binance Spot", account_type: "spot", currency: "USD") + account = Account.create!( + family: @family, name: "Binance Spot", balance: 0, currency: "USD", + accountable: Crypto.create!(subtype: "exchange") + ) + AccountProvider.create!(account: account, provider: ba1) + + # Unlinked account + @item.binance_accounts.create!(name: "Binance Earn", account_type: "earn", currency: "USD") + + assert_equal I18n.t("binance_items.binance_item.sync_status.partial_sync", linked_count: 1, unlinked_count: 1), @item.sync_status_summary + end + + test "linked_accounts_count returns correct count" do + ba = @item.binance_accounts.create!(name: "Binance", account_type: "combined", currency: "USD") + assert_equal 0, @item.linked_accounts_count + + account = Account.create!( + family: @family, name: "Binance", balance: 0, currency: "USD", + accountable: Crypto.create!(subtype: "exchange") + ) + AccountProvider.create!(account: account, provider: ba) + + assert_equal 1, @item.linked_accounts_count + end +end diff --git a/test/models/budget_category_test.rb b/test/models/budget_category_test.rb index 61335265e..d0f0b71a6 100644 --- a/test/models/budget_category_test.rb +++ b/test/models/budget_category_test.rb @@ -10,23 +10,20 @@ class BudgetCategoryTest < ActiveSupport::TestCase name: "Test Food & Groceries #{Time.now.to_f}", family: @family, color: "#4da568", - lucide_icon: "utensils", - classification: "expense" + lucide_icon: "utensils" ) # Create subcategories with unique names @subcategory_with_limit = Category.create!( name: "Test Restaurants #{Time.now.to_f}", parent: @parent_category, - family: @family, - classification: "expense" + family: @family ) @subcategory_inheriting = Category.create!( name: "Test Groceries #{Time.now.to_f}", parent: @parent_category, - family: @family, - classification: "expense" + family: @family ) # Create budget categories @@ -90,45 +87,6 @@ class BudgetCategoryTest < ActiveSupport::TestCase assert_equal 200, @subcategory_with_limit_bc.available_to_spend end - test "max_allocation excludes budgets of inheriting siblings" do - # Create another inheriting subcategory - another_inheriting = Category.create!( - name: "Test Coffee #{Time.now.to_f}", - parent: @parent_category, - family: @family, - classification: "expense" - ) - - another_inheriting_bc = BudgetCategory.create!( - budget: @budget, - category: another_inheriting, - budgeted_spending: 0, # Inherits - currency: "USD" - ) - - # Max allocation for new subcategory should only account for the one with explicit limit (300) - # 1000 (parent) - 300 (subcategory_with_limit) = 700 - assert_equal 700, another_inheriting_bc.max_allocation - - # If we add a new subcategory with a limit - new_subcategory_cat = Category.create!( - name: "Test Fast Food #{Time.now.to_f}", - parent: @parent_category, - family: @family, - classification: "expense" - ) - - new_subcategory_bc = BudgetCategory.create!( - budget: @budget, - category: new_subcategory_cat, - budgeted_spending: 0, - currency: "USD" - ) - - # Max should still be 700 because both inheriting subcategories don't count - assert_equal 700, new_subcategory_bc.max_allocation - end - test "percent_of_budget_spent for inheriting subcategory uses parent budget" do # Mock spending @budget.stubs(:budget_category_actual_spending).with(@subcategory_inheriting_bc).returns(100) @@ -143,8 +101,7 @@ class BudgetCategoryTest < ActiveSupport::TestCase name: "Test Entertainment #{Time.now.to_f}", family: @family, color: "#a855f7", - lucide_icon: "drama", - classification: "expense" + lucide_icon: "drama" ) standalone_bc = BudgetCategory.create!( @@ -162,6 +119,15 @@ class BudgetCategoryTest < ActiveSupport::TestCase assert_equal 40.0, standalone_bc.percent_of_budget_spent end + test "uncategorized budget category returns no subcategories" do + uncategorized_bc = BudgetCategory.uncategorized + uncategorized_bc.budget = @budget + + # Before the fix, this would return all top-level categories because + # category.id is nil, causing WHERE parent_id IS NULL to match all roots + assert_empty uncategorized_bc.subcategories + end + test "parent with only inheriting subcategories shares entire budget" do # Set subcategory_with_limit to also inherit @subcategory_with_limit_bc.update!(budgeted_spending: 0) @@ -176,4 +142,31 @@ class BudgetCategoryTest < ActiveSupport::TestCase assert_equal 800, @subcategory_with_limit_bc.available_to_spend assert_equal 800, @subcategory_inheriting_bc.available_to_spend end + + test "update_budgeted_spending! preserves positive parent reserve when subcategory becomes individual" do + @subcategory_inheriting_bc.update_budgeted_spending!(200) + + assert_equal 1200, @parent_budget_category.reload.budgeted_spending + assert_equal 200, @subcategory_inheriting_bc.reload.budgeted_spending + refute @subcategory_inheriting_bc.reload.inherits_parent_budget? + end + + test "update_budgeted_spending! lowers parent when subcategory returns to shared" do + @subcategory_with_limit_bc.update_budgeted_spending!(0) + + assert_equal 700, @parent_budget_category.reload.budgeted_spending + assert @subcategory_with_limit_bc.reload.inherits_parent_budget? + end + + test "update_budgeted_spending! does not preserve a negative parent reserve" do + # Create an artificial inconsistent parent total to verify recovery behavior. + @parent_budget_category.update!(budgeted_spending: 50) + @subcategory_inheriting_bc.update!(budgeted_spending: 50) + + @subcategory_with_limit_bc.update_budgeted_spending!(20) + + assert_equal 70, @parent_budget_category.reload.budgeted_spending + assert_equal 20, @subcategory_with_limit_bc.reload.budgeted_spending + assert_equal 50, @subcategory_inheriting_bc.reload.budgeted_spending + end end diff --git a/test/models/budget_test.rb b/test/models/budget_test.rb index cd3e95307..13f8f5a2b 100644 --- a/test/models/budget_test.rb +++ b/test/models/budget_test.rb @@ -82,8 +82,7 @@ class BudgetTest < ActiveSupport::TestCase healthcare = Category.create!( name: "Healthcare #{Time.now.to_f}", family: family, - color: "#e74c3c", - classification: "expense" + color: "#e74c3c" ) budget.sync_budget_categories @@ -129,8 +128,7 @@ class BudgetTest < ActiveSupport::TestCase category = Category.create!( name: "Returns Only #{Time.now.to_f}", family: family, - color: "#3498db", - classification: "expense" + color: "#3498db" ) budget.sync_budget_categories @@ -199,6 +197,101 @@ class BudgetTest < ActiveSupport::TestCase assert_equal 150, spending_without_refund - spending_with_refund end + test "most_recent_initialized_budget returns latest initialized budget before this one" do + family = families(:dylan_family) + + # Create an older initialized budget (2 months ago) + older_budget = Budget.create!( + family: family, + start_date: 2.months.ago.beginning_of_month, + end_date: 2.months.ago.end_of_month, + budgeted_spending: 3000, + expected_income: 5000, + currency: "USD" + ) + + # Create a middle uninitialized budget (1 month ago) + Budget.create!( + family: family, + start_date: 1.month.ago.beginning_of_month, + end_date: 1.month.ago.end_of_month, + currency: "USD" + ) + + current_budget = Budget.find_or_bootstrap(family, start_date: Date.current) + + assert_equal older_budget, current_budget.most_recent_initialized_budget + end + + test "most_recent_initialized_budget returns nil when none exist" do + family = families(:empty) + budget = Budget.create!( + family: family, + start_date: Date.current.beginning_of_month, + end_date: Date.current.end_of_month, + currency: "USD" + ) + + assert_nil budget.most_recent_initialized_budget + end + + test "copy_from copies budgeted_spending expected_income and matching category budgets" do + family = families(:dylan_family) + + # Use past months to avoid fixture conflict (fixture :one is at Date.current for dylan_family) + source_budget = Budget.find_or_bootstrap(family, start_date: 2.months.ago) + source_budget.update!(budgeted_spending: 4000, expected_income: 6000) + source_bc = source_budget.budget_categories.find_by(category: categories(:food_and_drink)) + source_bc.update!(budgeted_spending: 500) + + target_budget = Budget.find_or_bootstrap(family, start_date: 1.month.ago) + assert_nil target_budget.budgeted_spending + + target_budget.copy_from!(source_budget) + target_budget.reload + + assert_equal 4000, target_budget.budgeted_spending + assert_equal 6000, target_budget.expected_income + + target_bc = target_budget.budget_categories.find_by(category: categories(:food_and_drink)) + assert_equal 500, target_bc.budgeted_spending + end + + test "copy_from skips categories that dont exist in target" do + family = families(:dylan_family) + + source_budget = Budget.find_or_bootstrap(family, start_date: 2.months.ago) + source_budget.update!(budgeted_spending: 4000, expected_income: 6000) + + # Create a category only in the source budget + temp_category = Category.create!(name: "Temp #{Time.now.to_f}", family: family, color: "#aaaaaa") + source_budget.budget_categories.create!(category: temp_category, budgeted_spending: 100, currency: "USD") + + target_budget = Budget.find_or_bootstrap(family, start_date: 1.month.ago) + + # Should not raise even though target doesn't have the temp category + assert_nothing_raised { target_budget.copy_from!(source_budget) } + assert_equal 4000, target_budget.reload.budgeted_spending + end + + test "copy_from leaves new categories at zero" do + family = families(:dylan_family) + + source_budget = Budget.find_or_bootstrap(family, start_date: 2.months.ago) + source_budget.update!(budgeted_spending: 4000, expected_income: 6000) + + target_budget = Budget.find_or_bootstrap(family, start_date: 1.month.ago) + + # Add a new category only to the target + new_category = Category.create!(name: "New #{Time.now.to_f}", family: family, color: "#bbbbbb") + target_budget.budget_categories.create!(category: new_category, budgeted_spending: 0, currency: "USD") + + target_budget.copy_from!(source_budget) + + new_bc = target_budget.budget_categories.find_by(category: new_category) + assert_equal 0, new_bc.budgeted_spending + end + test "previous_budget_param returns param when date is valid" do budget = Budget.create!( family: @family, @@ -209,4 +302,30 @@ class BudgetTest < ActiveSupport::TestCase assert_not_nil budget.previous_budget_param end + + test "uncategorized budget category actual spending reflects uncategorized transactions" do + family = families(:dylan_family) + budget = Budget.find_or_bootstrap(family, start_date: Date.current.beginning_of_month) + account = accounts(:depository) + + # Create an uncategorized expense + Entry.create!( + account: account, + entryable: Transaction.create!(category: nil), + date: Date.current, + name: "Uncategorized lunch", + amount: 75, + currency: "USD" + ) + + budget = Budget.find(budget.id) + budget.sync_budget_categories + + uncategorized_bc = budget.uncategorized_budget_category + spending = budget.budget_category_actual_spending(uncategorized_bc) + + # Must be > 0 — the nil-key collision between Uncategorized and + # Other Investments synthetic categories previously caused this to return 0 + assert spending >= 75, "Uncategorized actual spending should include the $75 transaction, got #{spending}" + end end diff --git a/test/models/category_import_test.rb b/test/models/category_import_test.rb index 99e645c33..92128bd22 100644 --- a/test/models/category_import_test.rb +++ b/test/models/category_import_test.rb @@ -4,10 +4,10 @@ class CategoryImportTest < ActiveSupport::TestCase setup do @family = families(:dylan_family) @csv = <<~CSV - name,color,parent_category,classification,icon - Food & Drink,#f97316,,expense,carrot - Groceries,#407706,Food & Drink,expense,shopping-basket - Salary,#22c55e,,income,briefcase + name,color,parent_category,icon + Food & Drink,#f97316,,carrot + Groceries,#407706,Food & Drink,shopping-basket + Salary,#22c55e,,briefcase CSV end @@ -26,19 +26,17 @@ class CategoryImportTest < ActiveSupport::TestCase groceries = Category.find_by!(family: @family, name: "Groceries") salary = Category.find_by!(family: @family, name: "Salary") - assert_equal "expense", food.classification assert_equal "carrot", food.lucide_icon assert_equal food, groceries.parent assert_equal "shopping-basket", groceries.lucide_icon - assert_equal "income", salary.classification assert_equal "briefcase", salary.lucide_icon end test "imports subcategories even when parent row comes later" do csv = <<~CSV - name,color,parent_category,classification,icon - Utilities,#407706,Household,expense,plug - Household,#f97316,,expense,house + name,color,parent_category,icon + Utilities,#407706,Household,plug + Household,#f97316,,house CSV import = @family.imports.create!(type: "CategoryImport", raw_file_str: csv, col_sep: ",") @@ -55,9 +53,9 @@ class CategoryImportTest < ActiveSupport::TestCase test "updates categories when duplicate rows are provided" do csv = <<~CSV - name,color,parent_category,classification,icon - Snacks,#aaaaaa,,expense,cookie - Snacks,#bbbbbb,,expense,pizza + name,color,parent_category,icon + Snacks,#aaaaaa,,cookie + Snacks,#bbbbbb,,pizza CSV import = @family.imports.create!(type: "CategoryImport", raw_file_str: csv, col_sep: ",") @@ -72,8 +70,8 @@ class CategoryImportTest < ActiveSupport::TestCase test "accepts required headers with an asterisk suffix" do csv = <<~CSV - name*,color,parent_category,classification,icon - Food & Drink,#f97316,,expense,carrot + name*,color,parent_category,icon + Food & Drink,#f97316,,carrot CSV import = @family.imports.create!(type: "CategoryImport", raw_file_str: csv, col_sep: ",") @@ -85,8 +83,8 @@ class CategoryImportTest < ActiveSupport::TestCase test "fails fast when required headers are missing" do csv = <<~CSV - title,color,parent_category,classification,icon - Food & Drink,#f97316,,expense,carrot + title,color,parent_category,icon + Food & Drink,#f97316,,carrot CSV import = @family.imports.create!(type: "CategoryImport", raw_file_str: csv, col_sep: ",") diff --git a/test/models/category_test.rb b/test/models/category_test.rb index da4f9a48a..fc4036600 100644 --- a/test/models/category_test.rb +++ b/test/models/category_test.rb @@ -40,4 +40,19 @@ class CategoryTest < ActiveSupport::TestCase assert names.all? { |name| name.is_a?(String) } assert_equal names, names.uniq # No duplicates end + + test "should accept valid 6-digit hex colors" do + [ "#FFFFFF", "#000000", "#123456", "#ABCDEF", "#abcdef" ].each do |color| + category = Category.new(name: "Category #{color}", color: color, lucide_icon: "shapes", family: @family) + assert category.valid?, "#{color} should be valid" + end + end + + test "should reject invalid colors" do + [ "invalid", "#123", "#1234567", "#GGGGGG", "red", "ffffff", "#ffff", "" ].each do |color| + category = Category.new(name: "Category #{color}", color: color, lucide_icon: "shapes", family: @family) + assert_not category.valid?, "#{color} should be invalid" + assert_includes category.errors[:color], "is invalid" + end + end end diff --git a/test/models/coinstats_account/processor_test.rb b/test/models/coinstats_account/processor_test.rb index e0e556f96..c2c9f82f2 100644 --- a/test/models/coinstats_account/processor_test.rb +++ b/test/models/coinstats_account/processor_test.rb @@ -47,7 +47,7 @@ class CoinstatsAccount::ProcessorTest < ActiveSupport::TestCase @account.reload assert_equal BigDecimal("5000.50"), @account.balance - assert_equal BigDecimal("5000.50"), @account.cash_balance + assert_equal BigDecimal("0"), @account.cash_balance end test "updates account currency from coinstats account" do diff --git a/test/models/coinstats_account_test.rb b/test/models/coinstats_account_test.rb index a4a024d3c..804f79e33 100644 --- a/test/models/coinstats_account_test.rb +++ b/test/models/coinstats_account_test.rb @@ -290,4 +290,46 @@ class CoinstatsAccountTest < ActiveSupport::TestCase # Verify wallet A no longer exists assert_nil CoinstatsAccount.find_by(id: wallet_a.id) end + + test "portfolio exchange account derives total and cash balances from embedded coins" do + @family.update!(currency: "EUR") + + portfolio_account = @coinstats_item.coinstats_accounts.create!( + name: "Bitvavo", + currency: "EUR", + account_id: "exchange_portfolio:test", + wallet_address: "portfolio-test", + raw_payload: { + source: "exchange", + portfolio_account: true, + portfolio_id: "portfolio-test", + exchange_name: "Bitvavo", + coins: [ + { + coin: { identifier: "bitcoin", symbol: "BTC", name: "Bitcoin" }, + count: "0.00335845", + price: { EUR: "57950.0491" } + }, + { + coin: { identifier: "ethereum", symbol: "ETH", name: "Ethereum" }, + count: "0.05580825", + price: { EUR: "1728.952252246" } + }, + { + coin: { identifier: "FiatCoin:eur", symbol: "EUR", name: "Euro", isFiat: true }, + count: "2.58", + price: { EUR: "1" } + } + ] + } + ) + + assert portfolio_account.exchange_portfolio_account? + refute portfolio_account.fiat_asset? + assert_equal "EUR", portfolio_account.inferred_currency + assert_in_delta 293.69214193130284, portfolio_account.inferred_current_balance.to_f, 0.0001 + assert_in_delta 2.58, portfolio_account.inferred_cash_balance.to_f, 0.0001 + assert_equal 2, portfolio_account.portfolio_non_fiat_coins.size + assert_equal 1, portfolio_account.portfolio_fiat_coins.size + end end diff --git a/test/models/coinstats_entry/processor_test.rb b/test/models/coinstats_entry/processor_test.rb index 26ae19e0a..ab08c36e7 100644 --- a/test/models/coinstats_entry/processor_test.rb +++ b/test/models/coinstats_entry/processor_test.rb @@ -264,4 +264,202 @@ class CoinstatsEntry::ProcessorTest < ActiveSupport::TestCase processor.process end end + + test "restores legacy transaction entry if trade import fails" do + exchange_crypto = Crypto.create! + exchange_account_record = @family.accounts.create!( + accountable: exchange_crypto, + name: "Bitvavo", + balance: 1000, + currency: "USD" + ) + exchange_account = @coinstats_item.coinstats_accounts.create!( + name: "Bitvavo", + currency: "USD", + account_id: "exchange_portfolio:portfolio_123", + raw_payload: { + source: "exchange", + portfolio_account: true, + portfolio_id: "portfolio_123", + coins: [] + } + ) + AccountProvider.create!(account: exchange_account_record, provider: exchange_account) + + legacy_entry = exchange_account_record.entries.create!( + entryable: Transaction.new, + external_id: "coinstats_trade_legacy", + source: "coinstats", + amount: 100, + currency: "USD", + date: Date.new(2025, 1, 15), + name: "Trade BTC" + ) + + transaction_data = { + type: "Trade", + date: "2025-01-15T10:00:00.000Z", + hash: { id: "trade_legacy" }, + transactions: [ + { + items: [ + { coin: { id: "bitcoin", symbol: "BTC" }, count: "-0.1", totalWorth: "100" }, + { coin: { id: "ethereum", symbol: "ETH" }, count: "1.5", totalWorth: "100" } + ] + } + ] + } + + Security::Resolver.any_instance.stubs(:resolve).returns(securities(:aapl)) + Account::ProviderImportAdapter.any_instance.expects(:import_trade).raises(StandardError, "boom") + + processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: exchange_account) + + assert_raises(StandardError) { processor.process } + assert exchange_account_record.entries.exists?(id: legacy_entry.id) + end + + test "exchange trades prefer the disposed asset leg" do + exchange_crypto = Crypto.create! + exchange_account_record = @family.accounts.create!( + accountable: exchange_crypto, + name: "Bitvavo", + balance: 1000, + currency: "USD" + ) + exchange_account = @coinstats_item.coinstats_accounts.create!( + name: "Bitvavo", + currency: "USD", + account_id: "exchange_portfolio:portfolio_123", + raw_payload: { + source: "exchange", + portfolio_account: true, + portfolio_id: "portfolio_123", + coins: [] + } + ) + AccountProvider.create!(account: exchange_account_record, provider: exchange_account) + + transaction_data = { + type: "Trade", + date: "2025-01-15T10:00:00.000Z", + hash: { id: "trade_disposed_asset" }, + transactions: [ + { + items: [ + { coin: { id: "bitcoin", symbol: "BTC" }, count: "-0.00335845", totalWorth: "100" }, + { coin: { id: "ethereum", symbol: "ETH" }, count: "0.05580825", totalWorth: "100" } + ] + } + ] + } + + Security::Resolver.any_instance.stubs(:resolve).returns(securities(:aapl)) + + processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: exchange_account) + processor.process + + entry = exchange_account_record.entries.order(created_at: :desc).first + assert_equal "Trade BTC", entry.name + assert_equal "Sell", entry.trade.investment_activity_label + end + + test "portfolio exchange fallback keeps disposed asset sign when trade import is skipped" do + exchange_crypto = Crypto.create! + exchange_account_record = @family.accounts.create!( + accountable: exchange_crypto, + name: "Bitvavo", + balance: 1000, + currency: "USD" + ) + exchange_account = @coinstats_item.coinstats_accounts.create!( + name: "Bitvavo", + currency: "USD", + account_id: "exchange_portfolio:portfolio_123", + raw_payload: { + source: "exchange", + portfolio_account: true, + portfolio_id: "portfolio_123", + coins: [] + } + ) + AccountProvider.create!(account: exchange_account_record, provider: exchange_account) + + transaction_data = { + type: "Trade", + date: "2025-01-15T10:00:00.000Z", + hash: { id: "trade_fallback_sign" }, + transactions: [ + { + items: [ + { coin: { id: "bitcoin", symbol: "BTC" }, count: "-0.00335845", totalWorth: "100" }, + { coin: { id: "ethereum", symbol: "ETH" }, count: "0.05580825", totalWorth: "100" } + ] + } + ] + } + + Security::Resolver.any_instance.stubs(:resolve).returns(nil) + + processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: exchange_account) + processor.process + + entry = exchange_account_record.entries.order(created_at: :desc).first + assert_equal BigDecimal("100"), entry.amount + assert_equal "Trade BTC", entry.name + end + + test "preserves protected legacy transaction when migrating exchange trade" do + exchange_crypto = Crypto.create! + exchange_account_record = @family.accounts.create!( + accountable: exchange_crypto, + name: "Bitvavo", + balance: 1000, + currency: "USD" + ) + exchange_account = @coinstats_item.coinstats_accounts.create!( + name: "Bitvavo", + currency: "USD", + account_id: "exchange_portfolio:portfolio_123", + raw_payload: { + source: "exchange", + portfolio_account: true, + portfolio_id: "portfolio_123", + coins: [] + } + ) + AccountProvider.create!(account: exchange_account_record, provider: exchange_account) + + legacy_entry = exchange_account_record.entries.create!( + entryable: Transaction.new, + external_id: "coinstats_trade_protected", + source: "coinstats", + amount: 100, + currency: "USD", + date: Date.new(2025, 1, 15), + name: "Trade BTC" + ) + legacy_entry.mark_user_modified! + + transaction_data = { + type: "Trade", + date: "2025-01-15T10:00:00.000Z", + hash: { id: "trade_protected" }, + transactions: [ + { + items: [ + { coin: { id: "bitcoin", symbol: "BTC" }, count: "-0.00335845", totalWorth: "100" }, + { coin: { id: "ethereum", symbol: "ETH" }, count: "0.05580825", totalWorth: "100" } + ] + } + ] + } + + Security::Resolver.any_instance.stubs(:resolve).returns(securities(:aapl)) + + processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: exchange_account) + + assert_no_difference("Trade.count") { assert_equal legacy_entry, processor.process } + assert_equal "Transaction", legacy_entry.reload.entryable_type + end end diff --git a/test/models/coinstats_item/exchange_linker_test.rb b/test/models/coinstats_item/exchange_linker_test.rb new file mode 100644 index 000000000..bd1e39e0d --- /dev/null +++ b/test/models/coinstats_item/exchange_linker_test.rb @@ -0,0 +1,125 @@ +require "test_helper" + +class CoinstatsItem::ExchangeLinkerTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + @family.update!(currency: "EUR") + @coinstats_item = CoinstatsItem.create!( + family: @family, + name: "Test CoinStats Connection", + api_key: "test_api_key_123" + ) + end + + def success_response(data) + Provider::Response.new(success?: true, data: data, error: nil) + end + + test "link creates one exchange portfolio account with embedded coins" do + Provider::Coinstats.any_instance.expects(:exchange_options).returns([ + { + connection_id: "bitvavo", + name: "Bitvavo", + icon: "https://example.com/bitvavo.png", + connection_fields: [ + { key: "apiKey", name: "API Key" }, + { key: "apiSecret", name: "API Secret" } + ] + } + ]) + + Provider::Coinstats.any_instance.expects(:connect_portfolio_exchange) + .with( + connection_id: "bitvavo", + connection_fields: { "apiKey" => "key", "apiSecret" => "secret" }, + name: "Bitvavo Portfolio" + ) + .returns(success_response({ portfolioId: "portfolio_123" })) + + Provider::Coinstats.any_instance.expects(:list_portfolio_coins) + .with(portfolio_id: "portfolio_123") + .returns([ + { + coin: { identifier: "bitcoin", symbol: "BTC", name: "Bitcoin" }, + count: "0.00335845", + price: { EUR: "57950.0491" } + }, + { + coin: { identifier: "ethereum", symbol: "ETH", name: "Ethereum" }, + count: "0.05580825", + price: { EUR: "1728.952252246" } + }, + { + coin: { identifier: "FiatCoin:eur", symbol: "EUR", name: "Euro", isFiat: true }, + count: "2.58", + price: { EUR: "1" } + } + ]) + + @coinstats_item.expects(:sync_later).once + + assert_difference [ "CoinstatsAccount.count", "Account.count", "AccountProvider.count" ], 1 do + result = CoinstatsItem::ExchangeLinker.new( + @coinstats_item, + connection_id: "bitvavo", + connection_fields: { "apiKey" => "key", "apiSecret" => "secret" } + ).link + + assert result.success? + assert_equal 1, result.created_count + end + + @coinstats_item.reload + assert_equal "portfolio_123", @coinstats_item.exchange_portfolio_id + + coinstats_account = @coinstats_item.coinstats_accounts.last + assert coinstats_account.exchange_portfolio_account? + assert_equal "Bitvavo", coinstats_account.name + assert_equal "exchange_portfolio:portfolio_123", coinstats_account.account_id + assert_equal 3, coinstats_account.raw_payload["coins"].size + + account = coinstats_account.account + assert_equal "Bitvavo", account.name + assert_equal "EUR", account.currency + assert_in_delta 293.69214193130284, account.balance.to_f, 0.0001 + assert_in_delta 2.58, account.cash_balance.to_f, 0.0001 + end + + test "link defers local account creation when initial portfolio coin fetch is missing" do + Provider::Coinstats.any_instance.expects(:exchange_options).returns([ + { + connection_id: "bitvavo", + name: "Bitvavo", + icon: "https://example.com/bitvavo.png", + connection_fields: [ + { key: "apiKey", name: "API Key" } + ] + } + ]) + + Provider::Coinstats.any_instance.expects(:connect_portfolio_exchange) + .returns(success_response({ portfolioId: "portfolio_456" })) + + Provider::Coinstats.any_instance.expects(:list_portfolio_coins) + .with(portfolio_id: "portfolio_456") + .returns(nil) + + @coinstats_item.expects(:sync_later).once + + assert_no_difference [ "CoinstatsAccount.count", "Account.count", "AccountProvider.count" ] do + result = CoinstatsItem::ExchangeLinker.new( + @coinstats_item, + connection_id: "bitvavo", + connection_fields: { "apiKey" => "key" } + ).link + + assert result.success? + assert_equal 0, result.created_count + end + + @coinstats_item.reload + assert_equal "portfolio_456", @coinstats_item.exchange_portfolio_id + assert_equal "bitvavo", @coinstats_item.exchange_connection_id + assert_empty @coinstats_item.coinstats_accounts + end +end diff --git a/test/models/coinstats_item/importer_test.rb b/test/models/coinstats_item/importer_test.rb index 3f1de5c37..96ccafc0d 100644 --- a/test/models/coinstats_item/importer_test.rb +++ b/test/models/coinstats_item/importer_test.rb @@ -220,6 +220,121 @@ class CoinstatsItem::ImporterTest < ActiveSupport::TestCase assert_equal 0, result[:transactions_imported] end + test "preserves exchange portfolio snapshot when portfolio coin fetch is missing" do + crypto = Crypto.create! + account = @family.accounts.create!( + accountable: crypto, + name: "Bitvavo", + balance: 250, + cash_balance: 10, + currency: "EUR" + ) + + coinstats_account = @coinstats_item.coinstats_accounts.create!( + name: "Bitvavo", + currency: "EUR", + account_id: "exchange_portfolio:portfolio_123", + wallet_address: "portfolio_123", + current_balance: 250, + raw_payload: { + source: "exchange", + portfolio_account: true, + portfolio_id: "portfolio_123", + connection_id: "bitvavo", + exchange_name: "Bitvavo", + coins: [ + { + coin: { identifier: "bitcoin", symbol: "BTC", name: "Bitcoin" }, + count: "0.003", + price: { EUR: "80000" } + }, + { + coin: { identifier: "FiatCoin:eur", symbol: "EUR", name: "Euro", isFiat: true }, + count: "10", + price: { EUR: "1" } + } + ] + }, + raw_transactions_payload: [] + ) + AccountProvider.create!(account: account, provider: coinstats_account) + + @mock_provider.expects(:sync_exchange).with(portfolio_id: "portfolio_123").returns(success_response({})) + @mock_provider.expects(:list_exchange_transactions) + .with(portfolio_id: "portfolio_123", currency: "USD", from: nil) + .returns([]) + @mock_provider.expects(:list_portfolio_coins) + .with(portfolio_id: "portfolio_123") + .returns(nil) + + importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider) + + assert_no_changes -> { coinstats_account.reload.current_balance.to_f } do + result = importer.import + assert result[:success] + assert_equal 1, result[:accounts_updated] + assert_equal 0, result[:transactions_imported] + end + + reloaded = coinstats_account.reload + assert_equal "portfolio_123", reloaded.raw_payload["portfolio_id"] + assert_equal 2, reloaded.raw_payload["coins"].size + assert_equal 250.0, reloaded.current_balance.to_f + end + + test "writes an empty exchange portfolio snapshot when CoinStats returns an empty portfolio" do + crypto = Crypto.create! + account = @family.accounts.create!( + accountable: crypto, + name: "Bitvavo", + balance: 250, + cash_balance: 10, + currency: "EUR" + ) + + coinstats_account = @coinstats_item.coinstats_accounts.create!( + name: "Bitvavo", + currency: "EUR", + account_id: "exchange_portfolio:portfolio_123", + wallet_address: "portfolio_123", + current_balance: 250, + raw_payload: { + source: "exchange", + portfolio_account: true, + portfolio_id: "portfolio_123", + connection_id: "bitvavo", + exchange_name: "Bitvavo", + coins: [ + { + coin: { identifier: "bitcoin", symbol: "BTC", name: "Bitcoin" }, + count: "0.003", + price: { EUR: "80000" } + } + ] + }, + raw_transactions_payload: [] + ) + AccountProvider.create!(account: account, provider: coinstats_account) + + @mock_provider.expects(:sync_exchange).with(portfolio_id: "portfolio_123").returns(success_response({})) + @mock_provider.expects(:list_exchange_transactions) + .with(portfolio_id: "portfolio_123", currency: "USD", from: nil) + .returns([]) + @mock_provider.expects(:list_portfolio_coins) + .with(portfolio_id: "portfolio_123") + .returns([]) + + importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider) + result = importer.import + + assert result[:success] + assert_equal 1, result[:accounts_updated] + + reloaded = coinstats_account.reload + assert_equal 0.0, reloaded.current_balance.to_f + assert_equal [], reloaded.raw_payload["coins"] + end + test "calculates balance from matching token only, not all tokens" do # Create two accounts for different tokens in the same wallet crypto1 = Crypto.create! diff --git a/test/models/developer_message_test.rb b/test/models/developer_message_test.rb deleted file mode 100644 index 26d3d8e2a..000000000 --- a/test/models/developer_message_test.rb +++ /dev/null @@ -1,28 +0,0 @@ -require "test_helper" - -class DeveloperMessageTest < ActiveSupport::TestCase - setup do - @chat = chats(:one) - end - - test "does not broadcast" do - message = DeveloperMessage.create!(chat: @chat, content: "Some instructions") - message.update!(content: "updated") - - assert_no_turbo_stream_broadcasts(@chat) - end - - test "broadcasts if debug mode is enabled" do - with_env_overrides AI_DEBUG_MODE: "true" do - message = DeveloperMessage.create!(chat: @chat, content: "Some instructions") - message.update!(content: "updated") - - streams = capture_turbo_stream_broadcasts(@chat) - assert_equal 2, streams.size - assert_equal "append", streams.first["action"] - assert_equal "messages", streams.first["target"] - assert_equal "update", streams.last["action"] - assert_equal "developer_message_#{message.id}", streams.last["target"] - end - end -end diff --git a/test/models/enable_banking_entry/processor_test.rb b/test/models/enable_banking_entry/processor_test.rb new file mode 100644 index 000000000..33e55f46e --- /dev/null +++ b/test/models/enable_banking_entry/processor_test.rb @@ -0,0 +1,120 @@ +require "test_helper" + +class EnableBankingEntry::ProcessorTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + @account = accounts(:depository) + @enable_banking_item = EnableBankingItem.create!( + family: @family, + name: "Test Enable Banking", + country_code: "DE", + application_id: "test_app_id", + client_certificate: "test_cert" + ) + @enable_banking_account = EnableBankingAccount.create!( + enable_banking_item: @enable_banking_item, + name: "N26 Hauptkonto", + uid: "eb_uid_1", + currency: "EUR" + ) + AccountProvider.create!( + account: @account, + provider: @enable_banking_account + ) + end + + test "uses entry_reference as external_id when transaction_id is nil" do + tx = { + entry_reference: "31e13269-03fc-11f1-89d2-cd465703551c", + transaction_id: nil, + booking_date: Date.current.to_s, + transaction_amount: { amount: "11.65", currency: "EUR" }, + creditor: { name: "Spar Dankt 3418" }, + credit_debit_indicator: "DBIT", + status: "BOOK" + } + + assert_difference "@account.entries.count", 1 do + EnableBankingEntry::Processor.new(tx, enable_banking_account: @enable_banking_account).process + end + + entry = @account.entries.find_by!( + external_id: "enable_banking_31e13269-03fc-11f1-89d2-cd465703551c", + source: "enable_banking" + ) + assert_equal 11.65, entry.amount.to_f + assert_equal "EUR", entry.currency + end + + test "uses transaction_id as external_id when present" do + tx = { + entry_reference: "ref_123", + transaction_id: "txn_456", + booking_date: Date.current.to_s, + transaction_amount: { amount: "25.00", currency: "EUR" }, + creditor: { name: "Amazon" }, + credit_debit_indicator: "DBIT", + status: "BOOK" + } + + EnableBankingEntry::Processor.new(tx, enable_banking_account: @enable_banking_account).process + + entry = @account.entries.find_by!(external_id: "enable_banking_txn_456", source: "enable_banking") + assert_equal 25.0, entry.amount.to_f + end + + test "does not create duplicate when same entry_reference is processed twice" do + tx = { + entry_reference: "unique_ref_abc", + transaction_id: nil, + booking_date: Date.current.to_s, + transaction_amount: { amount: "50.00", currency: "EUR" }, + creditor: { name: "Rewe" }, + credit_debit_indicator: "DBIT", + status: "BOOK" + } + + assert_difference "@account.entries.count", 1 do + EnableBankingEntry::Processor.new(tx, enable_banking_account: @enable_banking_account).process + end + + assert_no_difference "@account.entries.count" do + EnableBankingEntry::Processor.new(tx, enable_banking_account: @enable_banking_account).process + end + end + + test "raises ArgumentError when both transaction_id and entry_reference are nil" do + tx = { + transaction_id: nil, + entry_reference: nil, + booking_date: Date.current.to_s, + transaction_amount: { amount: "10.00", currency: "EUR" }, + creditor: { name: "Test" }, + credit_debit_indicator: "DBIT", + status: "BOOK" + } + + assert_raises(ArgumentError) do + EnableBankingEntry::Processor.new(tx, enable_banking_account: @enable_banking_account).process + end + end + + test "handles string keys in transaction data" do + tx = { + "entry_reference" => "string_key_ref", + "transaction_id" => nil, + "booking_date" => Date.current.to_s, + "transaction_amount" => { "amount" => "15.00", "currency" => "EUR" }, + "creditor" => { "name" => "Lidl" }, + "credit_debit_indicator" => "DBIT", + "status" => "BOOK" + } + + assert_difference "@account.entries.count", 1 do + EnableBankingEntry::Processor.new(tx, enable_banking_account: @enable_banking_account).process + end + + entry = @account.entries.find_by!(external_id: "enable_banking_string_key_ref", source: "enable_banking") + assert_equal 15.0, entry.amount.to_f + end +end diff --git a/test/models/enable_banking_item/importer_dedup_test.rb b/test/models/enable_banking_item/importer_dedup_test.rb new file mode 100644 index 000000000..af6737a1a --- /dev/null +++ b/test/models/enable_banking_item/importer_dedup_test.rb @@ -0,0 +1,346 @@ +require "test_helper" + +class EnableBankingItem::ImporterDedupTest < ActiveSupport::TestCase + setup do + @family = families(:dylan_family) + @enable_banking_item = EnableBankingItem.create!( + family: @family, + name: "Test Enable Banking", + country_code: "AT", + application_id: "test_app_id", + client_certificate: "test_cert", + session_id: "test_session", + session_expires_at: 1.day.from_now + ) + + mock_provider = mock() + @importer = EnableBankingItem::Importer.new(@enable_banking_item, enable_banking_provider: mock_provider) + end + + test "removes content-level duplicates with different entry_reference IDs" do + transactions = [ + { + entry_reference: "ref_aaa", + transaction_id: nil, + booking_date: "2026-02-07", + transaction_amount: { amount: "11.65", currency: "EUR" }, + creditor: { name: "Spar Dankt 3418" }, + credit_debit_indicator: "DBIT", + status: "BOOK" + }, + { + entry_reference: "ref_bbb", + transaction_id: nil, + booking_date: "2026-02-07", + transaction_amount: { amount: "11.65", currency: "EUR" }, + creditor: { name: "Spar Dankt 3418" }, + credit_debit_indicator: "DBIT", + status: "BOOK" + } + ] + + result = @importer.send(:deduplicate_api_transactions, transactions) + + assert_equal 1, result.count + assert_equal "ref_aaa", result.first[:entry_reference] + end + + test "keeps transactions with different amounts" do + transactions = [ + { + entry_reference: "ref_1", + booking_date: "2026-02-07", + transaction_amount: { amount: "11.65", currency: "EUR" }, + creditor: { name: "Spar" }, + status: "BOOK" + }, + { + entry_reference: "ref_2", + booking_date: "2026-02-07", + transaction_amount: { amount: "23.30", currency: "EUR" }, + creditor: { name: "Spar" }, + status: "BOOK" + } + ] + + result = @importer.send(:deduplicate_api_transactions, transactions) + + assert_equal 2, result.count + end + + test "keeps transactions with different dates" do + transactions = [ + { + entry_reference: "ref_1", + booking_date: "2026-02-07", + transaction_amount: { amount: "11.65", currency: "EUR" }, + creditor: { name: "Spar" }, + status: "BOOK" + }, + { + entry_reference: "ref_2", + booking_date: "2026-02-08", + transaction_amount: { amount: "11.65", currency: "EUR" }, + creditor: { name: "Spar" }, + status: "BOOK" + } + ] + + result = @importer.send(:deduplicate_api_transactions, transactions) + + assert_equal 2, result.count + end + + test "keeps transactions with different creditors" do + transactions = [ + { + entry_reference: "ref_1", + booking_date: "2026-02-07", + transaction_amount: { amount: "11.65", currency: "EUR" }, + creditor: { name: "Spar" }, + status: "BOOK" + }, + { + entry_reference: "ref_2", + booking_date: "2026-02-07", + transaction_amount: { amount: "11.65", currency: "EUR" }, + creditor: { name: "Lidl" }, + status: "BOOK" + } + ] + + result = @importer.send(:deduplicate_api_transactions, transactions) + + assert_equal 2, result.count + end + + test "removes multiple duplicates from same response" do + base = { + booking_date: "2026-02-07", + transaction_amount: { amount: "3.00", currency: "EUR" }, + creditor: { name: "Bakery" }, + status: "BOOK" + } + + transactions = [ + base.merge(entry_reference: "ref_1"), + base.merge(entry_reference: "ref_2"), + base.merge(entry_reference: "ref_3") + ] + + result = @importer.send(:deduplicate_api_transactions, transactions) + + assert_equal 1, result.count + assert_equal "ref_1", result.first[:entry_reference] + end + + test "handles string keys in transaction data" do + transactions = [ + { + "entry_reference" => "ref_aaa", + "booking_date" => "2026-02-07", + "transaction_amount" => { "amount" => "11.65", "currency" => "EUR" }, + "creditor" => { "name" => "Spar" }, + "status" => "BOOK" + }, + { + "entry_reference" => "ref_bbb", + "booking_date" => "2026-02-07", + "transaction_amount" => { "amount" => "11.65", "currency" => "EUR" }, + "creditor" => { "name" => "Spar" }, + "status" => "BOOK" + } + ] + + result = @importer.send(:deduplicate_api_transactions, transactions) + + assert_equal 1, result.count + end + + test "differentiates by remittance_information" do + transactions = [ + { + entry_reference: "ref_1", + booking_date: "2026-02-07", + transaction_amount: { amount: "100.00", currency: "EUR" }, + creditor: { name: "Landlord" }, + remittance_information: [ "Rent January" ], + status: "BOOK" + }, + { + entry_reference: "ref_2", + booking_date: "2026-02-07", + transaction_amount: { amount: "100.00", currency: "EUR" }, + creditor: { name: "Landlord" }, + remittance_information: [ "Rent February" ], + status: "BOOK" + } + ] + + result = @importer.send(:deduplicate_api_transactions, transactions) + + assert_equal 2, result.count + end + + test "handles nil values in remittance_information array" do + transactions = [ + { + entry_reference: "ref_aaa", + booking_date: "2026-02-07", + transaction_amount: { amount: "11.65", currency: "EUR" }, + creditor: { name: "Spar" }, + remittance_information: [ nil, "Payment ref 123", nil ], + status: "BOOK" + }, + { + entry_reference: "ref_bbb", + booking_date: "2026-02-07", + transaction_amount: { amount: "11.65", currency: "EUR" }, + creditor: { name: "Spar" }, + remittance_information: [ "Payment ref 123", nil ], + status: "BOOK" + } + ] + + result = @importer.send(:deduplicate_api_transactions, transactions) + + assert_equal 1, result.count + assert_equal "ref_aaa", result.first[:entry_reference] + end + + test "preserves distinct transactions with same content but different transaction_ids" do + transactions = [ + { + entry_reference: "ref_1", + transaction_id: "txn_001", + booking_date: "2026-02-09", + transaction_amount: { amount: "1.50", currency: "EUR" }, + creditor: { name: "Waschsalon" }, + status: "BOOK" + }, + { + entry_reference: "ref_2", + transaction_id: "txn_002", + booking_date: "2026-02-09", + transaction_amount: { amount: "1.50", currency: "EUR" }, + creditor: { name: "Waschsalon" }, + status: "BOOK" + } + ] + + result = @importer.send(:deduplicate_api_transactions, transactions) + + assert_equal 2, result.count + end + + test "deduplicates same transaction_id even with different entry_references" do + transactions = [ + { + entry_reference: "ref_aaa", + transaction_id: "txn_same", + booking_date: "2026-02-09", + transaction_amount: { amount: "25.00", currency: "EUR" }, + creditor: { name: "Amazon" }, + status: "BOOK" + }, + { + entry_reference: "ref_bbb", + transaction_id: "txn_same", + booking_date: "2026-02-09", + transaction_amount: { amount: "25.00", currency: "EUR" }, + creditor: { name: "Amazon" }, + status: "BOOK" + } + ] + + result = @importer.send(:deduplicate_api_transactions, transactions) + + assert_equal 1, result.count + assert_equal "ref_aaa", result.first[:entry_reference] + end + + test "preserves transactions with same non-unique transaction_id but different content" do + # Per Enable Banking API docs, transaction_id is not guaranteed to be unique. + # Two transactions sharing a transaction_id but differing in content must both be kept. + transactions = [ + { + entry_reference: "ref_1", + transaction_id: "shared_tid", + booking_date: "2026-02-09", + transaction_amount: { amount: "25.00", currency: "EUR" }, + creditor: { name: "Amazon" }, + status: "BOOK" + }, + { + entry_reference: "ref_2", + transaction_id: "shared_tid", + booking_date: "2026-02-09", + transaction_amount: { amount: "42.00", currency: "EUR" }, + creditor: { name: "Amazon" }, + status: "BOOK" + } + ] + + result = @importer.send(:deduplicate_api_transactions, transactions) + + assert_equal 2, result.count + end + + test "deduplicates using value_date when booking_date is absent" do + transactions = [ + { + entry_reference: "ref_1", + transaction_id: nil, + value_date: "2026-02-10", + transaction_amount: { amount: "1.50", currency: "EUR" }, + creditor: { name: "Waschsalon" }, + status: "BOOK" + }, + { + entry_reference: "ref_2", + transaction_id: nil, + value_date: "2026-02-10", + transaction_amount: { amount: "1.50", currency: "EUR" }, + creditor: { name: "Waschsalon" }, + status: "BOOK" + } + ] + + result = @importer.send(:deduplicate_api_transactions, transactions) + + assert_equal 1, result.count + assert_equal "ref_1", result.first[:entry_reference] + end + + test "keeps payment and same-day refund with same amount as separate transactions" do + transactions = [ + { + entry_reference: "ref_payment", + transaction_id: nil, + booking_date: "2026-02-09", + transaction_amount: { amount: "25.00", currency: "EUR" }, + creditor: { name: "Amazon" }, + credit_debit_indicator: "DBIT", + status: "BOOK" + }, + { + entry_reference: "ref_refund", + transaction_id: nil, + booking_date: "2026-02-09", + transaction_amount: { amount: "25.00", currency: "EUR" }, + creditor: { name: "Amazon" }, + credit_debit_indicator: "CRDT", + status: "BOOK" + } + ] + + result = @importer.send(:deduplicate_api_transactions, transactions) + + assert_equal 2, result.count + end + + test "returns empty array for empty input" do + result = @importer.send(:deduplicate_api_transactions, []) + assert_equal [], result + end +end diff --git a/test/models/entry_split_test.rb b/test/models/entry_split_test.rb new file mode 100644 index 000000000..c9869210d --- /dev/null +++ b/test/models/entry_split_test.rb @@ -0,0 +1,177 @@ +require "test_helper" + +class EntrySplitTest < ActiveSupport::TestCase + include EntriesTestHelper + + setup do + @entry = create_transaction( + amount: 100, + name: "Grocery Store", + account: accounts(:depository), + category: categories(:food_and_drink) + ) + end + + test "split! creates child entries with correct amounts and marks parent excluded" do + splits = [ + { name: "Groceries", amount: 70, category_id: categories(:food_and_drink).id }, + { name: "Household", amount: 30, category_id: nil } + ] + + children = @entry.split!(splits) + + assert_equal 2, children.size + assert_equal 70, children.first.amount + assert_equal 30, children.last.amount + assert @entry.reload.excluded? + assert @entry.split_parent? + end + + test "split! rejects when amounts don't sum to parent" do + splits = [ + { name: "Part 1", amount: 60, category_id: nil }, + { name: "Part 2", amount: 30, category_id: nil } + ] + + assert_raises(ActiveRecord::RecordInvalid) do + @entry.split!(splits) + end + end + + test "split! allows mixed positive and negative amounts that sum to parent" do + splits = [ + { name: "Main expense", amount: 130, category_id: nil }, + { name: "Refund", amount: -30, category_id: nil } + ] + + children = @entry.split!(splits) + + assert_equal 2, children.size + assert_equal 130, children.first.amount + assert_equal(-30, children.last.amount) + end + + test "cannot split transfers" do + transfer = create_transfer( + from_account: accounts(:depository), + to_account: accounts(:credit_card), + amount: 100 + ) + outflow_transaction = transfer.outflow_transaction + + refute outflow_transaction.splittable? + end + + test "cannot split already-split parent" do + @entry.split!([ + { name: "Part 1", amount: 50, category_id: nil }, + { name: "Part 2", amount: 50, category_id: nil } + ]) + + refute @entry.entryable.splittable? + end + + test "cannot split child entry" do + children = @entry.split!([ + { name: "Part 1", amount: 50, category_id: nil }, + { name: "Part 2", amount: 50, category_id: nil } + ]) + + refute children.first.entryable.splittable? + end + + test "unsplit! removes children and restores parent" do + @entry.split!([ + { name: "Part 1", amount: 50, category_id: nil }, + { name: "Part 2", amount: 50, category_id: nil } + ]) + + assert @entry.reload.excluded? + assert_equal 2, @entry.child_entries.count + + @entry.unsplit! + + refute @entry.reload.excluded? + assert_equal 0, @entry.child_entries.count + end + + test "parent deletion cascades to children" do + @entry.split!([ + { name: "Part 1", amount: 50, category_id: nil }, + { name: "Part 2", amount: 50, category_id: nil } + ]) + + child_ids = @entry.child_entries.pluck(:id) + + @entry.destroy! + + assert_empty Entry.where(id: child_ids) + end + + test "individual child deletion is blocked" do + children = @entry.split!([ + { name: "Part 1", amount: 50, category_id: nil }, + { name: "Part 2", amount: 50, category_id: nil } + ]) + + refute children.first.destroy + assert children.first.persisted? + end + + test "split parent cannot be un-excluded" do + @entry.split!([ + { name: "Part 1", amount: 50, category_id: nil }, + { name: "Part 2", amount: 50, category_id: nil } + ]) + + @entry.reload + @entry.excluded = false + refute @entry.valid? + assert_includes @entry.errors[:excluded], "cannot be toggled off for a split transaction" + end + + test "excluding_split_parents scope excludes parents with children" do + @entry.split!([ + { name: "Part 1", amount: 50, category_id: nil }, + { name: "Part 2", amount: 50, category_id: nil } + ]) + + scope = Entry.excluding_split_parents.where(account: accounts(:depository)) + refute_includes scope.pluck(:id), @entry.id + assert_includes scope.pluck(:id), @entry.child_entries.first.id + end + + test "children inherit parent's account, date, and currency" do + children = @entry.split!([ + { name: "Part 1", amount: 50, category_id: nil }, + { name: "Part 2", amount: 50, category_id: nil } + ]) + + children.each do |child| + assert_equal @entry.account_id, child.account_id + assert_equal @entry.date, child.date + assert_equal @entry.currency, child.currency + end + end + + test "split_parent? returns true when entry has children" do + refute @entry.split_parent? + + @entry.split!([ + { name: "Part 1", amount: 50, category_id: nil }, + { name: "Part 2", amount: 50, category_id: nil } + ]) + + assert @entry.split_parent? + end + + test "split_child? returns true for child entries" do + children = @entry.split!([ + { name: "Part 1", amount: 50, category_id: nil }, + { name: "Part 2", amount: 50, category_id: nil } + ]) + + assert children.first.split_child? + refute @entry.split_child? + end +end diff --git a/test/models/family/data_exporter_test.rb b/test/models/family/data_exporter_test.rb index 9f36e16bf..e25ed0e5d 100644 --- a/test/models/family/data_exporter_test.rb +++ b/test/models/family/data_exporter_test.rb @@ -73,7 +73,7 @@ class Family::DataExporterTest < ActiveSupport::TestCase # Check categories.csv categories_csv = zip.read("categories.csv") - assert categories_csv.include?("name,color,parent_category,classification,lucide_icon") + assert categories_csv.include?("name,color,parent_category,lucide_icon") # Check rules.csv rules_csv = zip.read("rules.csv") diff --git a/test/models/family/data_importer_test.rb b/test/models/family/data_importer_test.rb new file mode 100644 index 000000000..29f4f5bb1 --- /dev/null +++ b/test/models/family/data_importer_test.rb @@ -0,0 +1,574 @@ +require "test_helper" + +class Family::DataImporterTest < ActiveSupport::TestCase + setup do + @family = families(:empty) + end + + test "imports accounts with accountable data" do + ndjson = build_ndjson([ + { + type: "Account", + data: { + id: "old-account-1", + name: "Test Checking", + balance: "1500.00", + currency: "USD", + accountable_type: "Depository", + accountable: { subtype: "checking" } + } + } + ]) + + importer = Family::DataImporter.new(@family, ndjson) + result = importer.import! + + assert_equal 1, result[:accounts].count + account = result[:accounts].first + assert_equal "Test Checking", account.name + assert_equal 1500.0, account.balance.to_f + assert_equal "USD", account.currency + assert_equal "Depository", account.accountable_type + end + + test "imports categories with parent relationships" do + ndjson = build_ndjson([ + { + type: "Category", + data: { + id: "cat-parent", + name: "Shopping", + color: "#FF5733", + classification: "expense" + } + }, + { + type: "Category", + data: { + id: "cat-child", + name: "Groceries", + color: "#33FF57", + classification: "expense", + parent_id: "cat-parent" + } + } + ]) + + importer = Family::DataImporter.new(@family, ndjson) + importer.import! + + parent = @family.categories.find_by(name: "Shopping") + child = @family.categories.find_by(name: "Groceries") + + assert_not_nil parent + assert_not_nil child + assert_equal parent.id, child.parent_id + end + + test "imports tags" do + ndjson = build_ndjson([ + { + type: "Tag", + data: { + id: "tag-1", + name: "Important", + color: "#FF0000" + } + } + ]) + + importer = Family::DataImporter.new(@family, ndjson) + importer.import! + + tag = @family.tags.find_by(name: "Important") + assert_not_nil tag + assert_equal "#FF0000", tag.color + end + + test "imports merchants" do + ndjson = build_ndjson([ + { + type: "Merchant", + data: { + id: "merchant-1", + name: "Amazon" + } + } + ]) + + importer = Family::DataImporter.new(@family, ndjson) + importer.import! + + merchant = @family.merchants.find_by(name: "Amazon") + assert_not_nil merchant + end + + test "imports transactions with references" do + ndjson = build_ndjson([ + { + type: "Account", + data: { + id: "acct-1", + name: "Main Account", + balance: "5000", + currency: "USD", + accountable_type: "Depository" + } + }, + { + type: "Category", + data: { + id: "cat-1", + name: "Food", + color: "#FF0000", + classification: "expense" + } + }, + { + type: "Tag", + data: { + id: "tag-1", + name: "Essential" + } + }, + { + type: "Transaction", + data: { + id: "txn-1", + account_id: "acct-1", + date: "2024-01-15", + amount: "-50.00", + name: "Grocery Store", + currency: "USD", + category_id: "cat-1", + tag_ids: [ "tag-1" ], + notes: "Weekly groceries" + } + } + ]) + + importer = Family::DataImporter.new(@family, ndjson) + result = importer.import! + + assert_equal 1, result[:entries].count + + transaction = @family.transactions.first + assert_not_nil transaction + assert_equal "Grocery Store", transaction.entry.name + assert_equal -50.0, transaction.entry.amount.to_f + assert_equal "Food", transaction.category.name + assert_equal 1, transaction.tags.count + assert_equal "Essential", transaction.tags.first.name + assert_equal "Weekly groceries", transaction.entry.notes + end + + test "imports trades with securities" do + ndjson = build_ndjson([ + { + type: "Account", + data: { + id: "inv-acct-1", + name: "Investment Account", + balance: "10000", + currency: "USD", + accountable_type: "Investment" + } + }, + { + type: "Trade", + data: { + id: "trade-1", + account_id: "inv-acct-1", + date: "2024-01-15", + ticker: "AAPL", + qty: "10", + price: "150.00", + amount: "-1500.00", + currency: "USD" + } + } + ]) + + importer = Family::DataImporter.new(@family, ndjson) + result = importer.import! + + # Account + Opening balance + Trade entry + assert_equal 1, result[:entries].count + + trade = @family.trades.first + assert_not_nil trade + assert_equal "AAPL", trade.security.ticker + assert_equal 10.0, trade.qty.to_f + assert_equal 150.0, trade.price.to_f + end + + test "imports valuations" do + ndjson = build_ndjson([ + { + type: "Account", + data: { + id: "prop-acct-1", + name: "Property", + balance: "500000", + currency: "USD", + accountable_type: "Property" + } + }, + { + type: "Valuation", + data: { + id: "val-1", + account_id: "prop-acct-1", + date: "2024-06-15", + amount: "520000", + name: "Updated valuation", + currency: "USD" + } + } + ]) + + importer = Family::DataImporter.new(@family, ndjson) + result = importer.import! + + assert_equal 1, result[:entries].count + + account = @family.accounts.find_by(name: "Property") + valuation = account.valuations.joins(:entry).find_by(entries: { name: "Updated valuation" }) + assert_not_nil valuation + assert_equal 520000.0, valuation.entry.amount.to_f + end + + test "imports budgets" do + ndjson = build_ndjson([ + { + type: "Budget", + data: { + id: "budget-1", + start_date: "2024-01-01", + end_date: "2024-01-31", + budgeted_spending: "3000.00", + expected_income: "5000.00", + currency: "USD" + } + } + ]) + + importer = Family::DataImporter.new(@family, ndjson) + importer.import! + + budget = @family.budgets.first + assert_not_nil budget + assert_equal Date.parse("2024-01-01"), budget.start_date + assert_equal Date.parse("2024-01-31"), budget.end_date + assert_equal 3000.0, budget.budgeted_spending.to_f + assert_equal 5000.0, budget.expected_income.to_f + end + + test "imports budget_categories" do + ndjson = build_ndjson([ + { + type: "Category", + data: { + id: "cat-groceries", + name: "Groceries", + color: "#00FF00", + classification: "expense" + } + }, + { + type: "Budget", + data: { + id: "budget-1", + start_date: "2024-01-01", + end_date: "2024-01-31", + budgeted_spending: "3000.00", + expected_income: "5000.00", + currency: "USD" + } + }, + { + type: "BudgetCategory", + data: { + id: "bc-1", + budget_id: "budget-1", + category_id: "cat-groceries", + budgeted_spending: "500.00", + currency: "USD" + } + } + ]) + + importer = Family::DataImporter.new(@family, ndjson) + importer.import! + + budget = @family.budgets.first + budget_category = budget.budget_categories.first + assert_not_nil budget_category + assert_equal "Groceries", budget_category.category.name + assert_equal 500.0, budget_category.budgeted_spending.to_f + end + + test "imports rules with conditions and actions" do + ndjson = build_ndjson([ + { + type: "Rule", + version: 1, + data: { + name: "Categorize Coffee", + resource_type: "transaction", + active: true, + conditions: [ + { + condition_type: "transaction_name", + operator: "like", + value: "starbucks" + } + ], + actions: [ + { + action_type: "set_transaction_category", + value: "Coffee" + } + ] + } + } + ]) + + importer = Family::DataImporter.new(@family, ndjson) + importer.import! + + rule = @family.rules.find_by(name: "Categorize Coffee") + assert_not_nil rule + assert rule.active + assert_equal "transaction", rule.resource_type + + assert_equal 1, rule.conditions.count + condition = rule.conditions.first + assert_equal "transaction_name", condition.condition_type + assert_equal "like", condition.operator + assert_equal "starbucks", condition.value + + assert_equal 1, rule.actions.count + action = rule.actions.first + assert_equal "set_transaction_category", action.action_type + + # Category should be created + category = @family.categories.find_by(name: "Coffee") + assert_not_nil category + assert_equal category.id, action.value + end + + test "imports rules with compound conditions" do + ndjson = build_ndjson([ + { + type: "Rule", + version: 1, + data: { + name: "Compound Rule", + resource_type: "transaction", + active: true, + conditions: [ + { + condition_type: "compound", + operator: "or", + sub_conditions: [ + { + condition_type: "transaction_name", + operator: "like", + value: "walmart" + }, + { + condition_type: "transaction_name", + operator: "like", + value: "target" + } + ] + } + ], + actions: [ + { + action_type: "auto_categorize" + } + ] + } + } + ]) + + importer = Family::DataImporter.new(@family, ndjson) + importer.import! + + rule = @family.rules.find_by(name: "Compound Rule") + assert_not_nil rule + + parent_condition = rule.conditions.first + assert_equal "compound", parent_condition.condition_type + assert_equal "or", parent_condition.operator + assert_equal 2, parent_condition.sub_conditions.count + end + + test "skips invalid records gracefully" do + ndjson = "not valid json\n" + build_ndjson([ + { + type: "Account", + data: { + id: "valid-acct", + name: "Valid Account", + balance: "1000", + currency: "USD", + accountable_type: "Depository" + } + } + ]) + + importer = Family::DataImporter.new(@family, ndjson) + result = importer.import! + + assert_equal 1, result[:accounts].count + assert_equal "Valid Account", result[:accounts].first.name + end + + test "skips unsupported record types" do + ndjson = build_ndjson([ + { + type: "UnsupportedType", + data: { id: "unknown" } + }, + { + type: "Account", + data: { + id: "valid-acct", + name: "Known Account", + balance: "1000", + currency: "USD", + accountable_type: "Depository" + } + } + ]) + + importer = Family::DataImporter.new(@family, ndjson) + result = importer.import! + + assert_equal 1, result[:accounts].count + end + + test "full import scenario with all entity types" do + ndjson = build_ndjson([ + # Account + { + type: "Account", + data: { + id: "acct-main", + name: "Main Checking", + balance: "5000", + currency: "USD", + accountable_type: "Depository" + } + }, + # Category + { + type: "Category", + data: { + id: "cat-food", + name: "Food", + color: "#FF5733", + classification: "expense" + } + }, + # Tag + { + type: "Tag", + data: { + id: "tag-weekly", + name: "Weekly" + } + }, + # Merchant + { + type: "Merchant", + data: { + id: "merchant-1", + name: "Local Grocery" + } + }, + # Transaction + { + type: "Transaction", + data: { + id: "txn-1", + account_id: "acct-main", + date: "2024-01-15", + amount: "-75.50", + name: "Weekly groceries", + currency: "USD", + category_id: "cat-food", + merchant_id: "merchant-1", + tag_ids: [ "tag-weekly" ] + } + }, + # Budget + { + type: "Budget", + data: { + id: "budget-jan", + start_date: "2024-01-01", + end_date: "2024-01-31", + budgeted_spending: "2000", + expected_income: "4000", + currency: "USD" + } + }, + # BudgetCategory + { + type: "BudgetCategory", + data: { + id: "bc-food", + budget_id: "budget-jan", + category_id: "cat-food", + budgeted_spending: "500", + currency: "USD" + } + }, + # Rule + { + type: "Rule", + version: 1, + data: { + name: "Auto-tag groceries", + resource_type: "transaction", + active: true, + conditions: [ + { condition_type: "transaction_name", operator: "like", value: "grocery" } + ], + actions: [ + { action_type: "set_transaction_tags", value: "Weekly" } + ] + } + } + ]) + + importer = Family::DataImporter.new(@family, ndjson) + result = importer.import! + + # Verify all entities were created + assert_equal 1, result[:accounts].count + assert_equal 1, @family.categories.count + assert_equal 1, @family.tags.count + assert_equal 1, @family.merchants.count + assert_equal 1, @family.transactions.count + assert_equal 1, @family.budgets.count + assert_equal 1, @family.budget_categories.count + assert_equal 1, @family.rules.count + + # Verify relationships + transaction = @family.transactions.first + assert_equal "Food", transaction.category.name + assert_equal "Local Grocery", transaction.merchant.name + assert_equal "Weekly", transaction.tags.first.name + end + + private + + def build_ndjson(records) + records.map(&:to_json).join("\n") + end +end diff --git a/test/models/family/subscribeable_test.rb b/test/models/family/subscribeable_test.rb index 0b1aafe71..7fda5ec58 100644 --- a/test/models/family/subscribeable_test.rb +++ b/test/models/family/subscribeable_test.rb @@ -25,4 +25,86 @@ class Family::SubscribeableTest < ActiveSupport::TestCase @family.update!(stripe_customer_id: "") assert_not @family.can_manage_subscription? end + + test "inactive_trial_for_cleanup includes families with expired paused trials" do + inactive = families(:inactive_trial) + results = Family.inactive_trial_for_cleanup + + assert_includes results, inactive + end + + test "inactive_trial_for_cleanup excludes families with active subscriptions" do + results = Family.inactive_trial_for_cleanup + + assert_not_includes results, @family + end + + test "inactive_trial_for_cleanup excludes families within grace period" do + inactive = families(:inactive_trial) + inactive.subscription.update!(trial_ends_at: 5.days.ago) + + results = Family.inactive_trial_for_cleanup + + assert_not_includes results, inactive + end + + test "inactive_trial_for_cleanup includes families with no subscription created long ago" do + old_family = Family.create!(name: "Abandoned", created_at: 90.days.ago) + + results = Family.inactive_trial_for_cleanup + + assert_includes results, old_family + + old_family.destroy + end + + test "inactive_trial_for_cleanup excludes recently created families with no subscription" do + recent_family = Family.create!(name: "New") + + results = Family.inactive_trial_for_cleanup + + assert_not_includes results, recent_family + + recent_family.destroy + end + + test "requires_data_archive? returns false with few transactions" do + inactive = families(:inactive_trial) + assert_not inactive.requires_data_archive? + end + + test "requires_data_archive? returns true with 12+ recent transactions" do + inactive = families(:inactive_trial) + account = inactive.accounts.create!( + name: "Test", currency: "USD", balance: 0, accountable: Depository.new, status: :active + ) + + trial_end = inactive.subscription.trial_ends_at + 15.times do |i| + account.entries.create!( + name: "Txn #{i}", date: trial_end - i.days, amount: 10, currency: "USD", + entryable: Transaction.new + ) + end + + assert inactive.requires_data_archive? + end + + test "requires_data_archive? returns false with 12+ transactions but none recent" do + inactive = families(:inactive_trial) + account = inactive.accounts.create!( + name: "Test", currency: "USD", balance: 0, accountable: Depository.new, status: :active + ) + + # All transactions from early in the trial (more than 14 days before trial end) + trial_end = inactive.subscription.trial_ends_at + 15.times do |i| + account.entries.create!( + name: "Txn #{i}", date: trial_end - 30.days - i.days, amount: 10, currency: "USD", + entryable: Transaction.new + ) + end + + assert_not inactive.requires_data_archive? + end end diff --git a/test/models/family_test.rb b/test/models/family_test.rb index bfff617be..69a530316 100644 --- a/test/models/family_test.rb +++ b/test/models/family_test.rb @@ -18,7 +18,6 @@ class FamilyTest < ActiveSupport::TestCase assert category.persisted? assert_equal Category.investment_contributions_name, category.name assert_equal "#0d9488", category.color - assert_equal "expense", category.classification assert_equal "trending-up", category.lucide_icon end @@ -26,7 +25,6 @@ class FamilyTest < ActiveSupport::TestCase family = families(:dylan_family) existing = family.categories.find_or_create_by!(name: Category.investment_contributions_name) do |c| c.color = "#0d9488" - c.classification = "expense" c.lucide_icon = "trending-up" end @@ -89,7 +87,6 @@ class FamilyTest < ActiveSupport::TestCase legacy_category = family.categories.create!( name: "Investment Contributions", color: "#0d9488", - classification: "expense", lucide_icon: "trending-up" ) @@ -110,14 +107,12 @@ class FamilyTest < ActiveSupport::TestCase english_category = family.categories.create!( name: "Investment Contributions", color: "#0d9488", - classification: "expense", lucide_icon: "trending-up" ) french_category = family.categories.create!( name: "Contributions aux investissements", color: "#0d9488", - classification: "expense", lucide_icon: "trending-up" ) diff --git a/test/models/holding/materializer_test.rb b/test/models/holding/materializer_test.rb index 4ab68e10f..4d14a26c8 100644 --- a/test/models/holding/materializer_test.rb +++ b/test/models/holding/materializer_test.rb @@ -93,4 +93,34 @@ class Holding::MaterializerTest < ActiveSupport::TestCase assert_equal "calculated", holding.cost_basis_source assert_equal BigDecimal("2750.0"), holding.cost_basis end + + test "preserves calculated history for provider-sourced holdings on reverse materialization" do + coinstats_item = @family.coinstats_items.create!(name: "CoinStats", api_key: "test-key") + coinstats_account = coinstats_item.coinstats_accounts.create!( + name: "Brokerage", + currency: "USD" + ) + account_provider = AccountProvider.create!(account: @account, provider: coinstats_account) + + Holding.create!( + account: @account, + security: @aapl, + qty: 10, + price: 200, + amount: 2000, + currency: "USD", + date: Date.current, + account_provider: account_provider + ) + + Holding::Materializer.new(@account, strategy: :reverse).materialize_holdings + + today_holding = @account.holdings.find_by!(security: @aapl, date: Date.current, currency: "USD") + yesterday_holding = @account.holdings.find_by!(security: @aapl, date: Date.yesterday, currency: "USD") + + assert_equal account_provider.id, today_holding.account_provider_id + assert_nil yesterday_holding.account_provider_id + assert_equal BigDecimal("10"), yesterday_holding.qty + assert_equal yesterday_holding.qty * yesterday_holding.price, yesterday_holding.amount + end end diff --git a/test/models/holding/portfolio_snapshot_test.rb b/test/models/holding/portfolio_snapshot_test.rb index 624e6086e..341f7eaa3 100644 --- a/test/models/holding/portfolio_snapshot_test.rb +++ b/test/models/holding/portfolio_snapshot_test.rb @@ -47,4 +47,41 @@ class Holding::PortfolioSnapshotTest < ActiveSupport::TestCase assert_equal 1, portfolio.size assert_equal 0, portfolio[@aapl.id] end + + test "prefers the latest provider snapshot over newer calculated holdings" do + @account.holdings.destroy_all + @account.entries.destroy_all + + create_trade(@aapl, account: @account, qty: 10, price: 100, date: 5.days.ago) + create_trade(@msft, account: @account, qty: 5, price: 200, date: 5.days.ago) + + coinstats_item = @account.family.coinstats_items.create!(name: "CoinStats", api_key: "test-key") + coinstats_account = coinstats_item.coinstats_accounts.create!(name: "Provider", currency: "USD") + account_provider = AccountProvider.create!(account: @account, provider: coinstats_account) + + @account.holdings.create!( + security: @aapl, + date: 1.day.ago, + qty: 10, + price: 100, + amount: 1000, + currency: "USD", + account_provider: account_provider + ) + + @account.holdings.create!( + security: @msft, + date: Date.current, + qty: 5, + price: 200, + amount: 1000, + currency: "USD" + ) + + portfolio = Holding::PortfolioSnapshot.new(@account).to_h + + assert_equal 2, portfolio.size + assert_equal 10, portfolio[@aapl.id] + assert_equal 0, portfolio[@msft.id] + end end diff --git a/test/models/income_statement_test.rb b/test/models/income_statement_test.rb index 14280ec1c..f1dc44435 100644 --- a/test/models/income_statement_test.rb +++ b/test/models/income_statement_test.rb @@ -6,9 +6,9 @@ class IncomeStatementTest < ActiveSupport::TestCase setup do @family = families(:empty) - @income_category = @family.categories.create! name: "Income", classification: "income" - @food_category = @family.categories.create! name: "Food", classification: "expense" - @groceries_category = @family.categories.create! name: "Groceries", classification: "expense", parent: @food_category + @income_category = @family.categories.create! name: "Income" + @food_category = @family.categories.create! name: "Food" + @groceries_category = @family.categories.create! name: "Groceries", parent: @food_category @checking_account = @family.accounts.create! name: "Checking", currency: @family.currency, balance: 5000, accountable: Depository.new @credit_card_account = @family.accounts.create! name: "Credit Card", currency: @family.currency, balance: 1000, accountable: CreditCard.new @@ -114,7 +114,7 @@ class IncomeStatementTest < ActiveSupport::TestCase Entry.joins(:account).where(accounts: { family_id: @family.id }).destroy_all # Create different amounts for groceries vs other food - other_food_category = @family.categories.create! name: "Restaurants", classification: "expense", parent: @food_category + other_food_category = @family.categories.create! name: "Restaurants", parent: @food_category # Groceries: 100, 300, 500 (median = 300) create_transaction(account: @checking_account, amount: 100, category: @groceries_category) @@ -497,6 +497,55 @@ class IncomeStatementTest < ActiveSupport::TestCase refute_includes tax_advantaged_ids, @credit_card_account.id end + # net_category_totals tests + test "net_category_totals nets expense and refund in the same category" do + Entry.joins(:account).where(accounts: { family_id: @family.id }).destroy_all + + # $200 expense and $50 refund both on Food + create_transaction(account: @checking_account, amount: 200, category: @food_category) + create_transaction(account: @checking_account, amount: -50, category: @food_category) + + net = IncomeStatement.new(@family).net_category_totals(period: Period.last_30_days) + + assert_equal 150, net.total_net_expense + assert_equal 0, net.total_net_income + + food_net = net.net_expense_categories.find { |ct| ct.category.id == @food_category.id } + assert_not_nil food_net + assert_equal 150, food_net.total + assert_in_delta 100.0, food_net.weight, 0.1 + end + + test "net_category_totals places category on income side when refunds exceed expenses" do + Entry.joins(:account).where(accounts: { family_id: @family.id }).destroy_all + + # $100 expense but $250 refund on Food => net income of 150 + create_transaction(account: @checking_account, amount: 100, category: @food_category) + create_transaction(account: @checking_account, amount: -250, category: @food_category) + + net = IncomeStatement.new(@family).net_category_totals(period: Period.last_30_days) + + assert_equal 0, net.total_net_expense + assert_equal 150, net.total_net_income + + food_net = net.net_income_categories.find { |ct| ct.category.id == @food_category.id } + assert_not_nil food_net + assert_equal 150, food_net.total + + # Should not appear on expense side + assert_nil net.net_expense_categories.find { |ct| ct.category.id == @food_category.id } + end + + test "empty account_ids returns no results for category stats" do + results = IncomeStatement::CategoryStats.new(@family, account_ids: []).call + assert_empty results + end + + test "empty account_ids returns no results for family stats" do + results = IncomeStatement::FamilyStats.new(@family, account_ids: []).call + assert_empty results + end + test "returns zero totals when family has only tax-advantaged accounts" do # Create a fresh family with ONLY tax-advantaged accounts family_only_retirement = Family.create!( diff --git a/test/models/invitation_test.rb b/test/models/invitation_test.rb index 710b4447e..9895538e8 100644 --- a/test/models/invitation_test.rb +++ b/test/models/invitation_test.rb @@ -62,6 +62,66 @@ class InvitationTest < ActiveSupport::TestCase assert_not result end + test "cannot create invitation when email has pending invitation from another family" do + other_family = families(:empty) + other_inviter = users(:empty) + other_inviter.update_columns(family_id: other_family.id, role: "admin") + + email = "cross-family-test@example.com" + + # Create a pending invitation in the first family + @family.invitations.create!(email: email, role: "member", inviter: @inviter) + + # Attempting to create a pending invitation in a different family should fail + invitation = other_family.invitations.build(email: email, role: "member", inviter: other_inviter) + assert_not invitation.valid? + assert_includes invitation.errors[:email], "already has a pending invitation from another family" + end + + test "can create invitation when existing invitation from another family is accepted" do + other_family = families(:empty) + other_inviter = users(:empty) + other_inviter.update_columns(family_id: other_family.id, role: "admin") + + email = "cross-family-accepted@example.com" + + # Create an accepted invitation in the first family + accepted_invitation = @family.invitations.create!(email: email, role: "member", inviter: @inviter) + accepted_invitation.update!(accepted_at: Time.current) + + # Should be able to create a pending invitation in a different family + invitation = other_family.invitations.build(email: email, role: "member", inviter: other_inviter) + assert invitation.valid? + end + + test "can create invitation when existing invitation from another family is expired" do + other_family = families(:empty) + other_inviter = users(:empty) + other_inviter.update_columns(family_id: other_family.id, role: "admin") + + email = "cross-family-expired@example.com" + + # Create an expired invitation in the first family + expired_invitation = @family.invitations.create!(email: email, role: "member", inviter: @inviter) + expired_invitation.update_columns(expires_at: 1.day.ago) + + # Should be able to create a pending invitation in a different family + invitation = other_family.invitations.build(email: email, role: "member", inviter: other_inviter) + assert invitation.valid? + end + + test "can create invitation in same family (uniqueness scoped to family)" do + email = "same-family-test@example.com" + + # Create a pending invitation in the family + @family.invitations.create!(email: email, role: "member", inviter: @inviter) + + # Attempting to create another in the same family should fail due to the existing scope validation + invitation = @family.invitations.build(email: email, role: "admin", inviter: @inviter) + assert_not invitation.valid? + assert_includes invitation.errors[:email], "has already been invited to this family" + end + test "accept_for applies guest role defaults" do user = users(:family_member) user.update!( diff --git a/test/models/mercury_account_test.rb b/test/models/mercury_account_test.rb new file mode 100644 index 000000000..801728bdf --- /dev/null +++ b/test/models/mercury_account_test.rb @@ -0,0 +1,76 @@ +require "test_helper" + +class MercuryAccountTest < ActiveSupport::TestCase + setup do + @family_a = families(:dylan_family) + @family_b = families(:empty) + + @item_a = MercuryItem.create!( + family: @family_a, + name: "Family A Mercury", + token: "token_a", + base_url: "https://api-sandbox.mercury.com/api/v1", + status: "good" + ) + + @item_b = MercuryItem.create!( + family: @family_b, + name: "Family B Mercury", + token: "token_b", + base_url: "https://api-sandbox.mercury.com/api/v1", + status: "good" + ) + end + + test "same account_id can be linked under different mercury_items" do + MercuryAccount.create!( + mercury_item: @item_a, + account_id: "shared_merc_acc_1", + name: "Checking", + currency: "USD", + current_balance: 5000 + ) + + # A second family connecting the same Mercury account must succeed and produce + # an independent ledger (separate MercuryAccount row, separate Account). + assert_difference "MercuryAccount.count", 1 do + MercuryAccount.create!( + mercury_item: @item_b, + account_id: "shared_merc_acc_1", + name: "Checking", + currency: "USD", + current_balance: 5000 + ) + end + end + + test "same account_id cannot appear twice under the same mercury_item" do + MercuryAccount.create!( + mercury_item: @item_a, + account_id: "duplicate_acc", + name: "Checking", + currency: "USD", + current_balance: 1000 + ) + + duplicate = MercuryAccount.new( + mercury_item: @item_a, + account_id: "duplicate_acc", + name: "Checking", + currency: "USD", + current_balance: 1000 + ) + refute duplicate.valid? + assert_includes duplicate.errors[:account_id], "has already been taken" + + assert_raises(ActiveRecord::RecordInvalid) do + MercuryAccount.create!( + mercury_item: @item_a, + account_id: "duplicate_acc", + name: "Checking", + currency: "USD", + current_balance: 1000 + ) + end + end +end diff --git a/test/models/plaid_account/transactions/category_matcher_test.rb b/test/models/plaid_account/transactions/category_matcher_test.rb index 35bcf8fe2..f01a62889 100644 --- a/test/models/plaid_account/transactions/category_matcher_test.rb +++ b/test/models/plaid_account/transactions/category_matcher_test.rb @@ -5,9 +5,9 @@ class PlaidAccount::Transactions::CategoryMatcherTest < ActiveSupport::TestCase @family = families(:empty) # User income categories - @income = @family.categories.create!(name: "Income", classification: "income") - @dividend_income = @family.categories.create!(name: "Dividend Income", parent: @income, classification: "income") - @interest_income = @family.categories.create!(name: "Interest Income", parent: @income, classification: "income") + @income = @family.categories.create!(name: "Income") + @dividend_income = @family.categories.create!(name: "Dividend Income", parent: @income) + @interest_income = @family.categories.create!(name: "Interest Income", parent: @income) # User expense categories @loan_payments = @family.categories.create!(name: "Loan Payments") diff --git a/test/models/plaid_account_test.rb b/test/models/plaid_account_test.rb new file mode 100644 index 000000000..46f0f6d20 --- /dev/null +++ b/test/models/plaid_account_test.rb @@ -0,0 +1,77 @@ +require "test_helper" + +class PlaidAccountTest < ActiveSupport::TestCase + setup do + @family_a = families(:dylan_family) + @family_b = families(:empty) + + @item_a = PlaidItem.create!( + family: @family_a, + name: "Family A Bank", + plaid_id: "item_a_#{SecureRandom.hex(4)}", + access_token: "token_a" + ) + + @item_b = PlaidItem.create!( + family: @family_b, + name: "Family B Bank", + plaid_id: "item_b_#{SecureRandom.hex(4)}", + access_token: "token_b" + ) + end + + test "same plaid_id can be linked under different plaid_items" do + PlaidAccount.create!( + plaid_item: @item_a, + plaid_id: "shared_plaid_acc_1", + name: "Checking", + plaid_type: "depository", + currency: "USD", + current_balance: 5000 + ) + + assert_difference "PlaidAccount.count", 1 do + PlaidAccount.create!( + plaid_item: @item_b, + plaid_id: "shared_plaid_acc_1", + name: "Checking", + plaid_type: "depository", + currency: "USD", + current_balance: 5000 + ) + end + end + + test "same plaid_id cannot appear twice under the same plaid_item" do + PlaidAccount.create!( + plaid_item: @item_a, + plaid_id: "duplicate_plaid", + name: "Checking", + plaid_type: "depository", + currency: "USD", + current_balance: 1000 + ) + + duplicate = PlaidAccount.new( + plaid_item: @item_a, + plaid_id: "duplicate_plaid", + name: "Checking", + plaid_type: "depository", + currency: "USD", + current_balance: 1000 + ) + refute duplicate.valid? + assert_includes duplicate.errors[:plaid_id], "has already been taken" + + assert_raises(ActiveRecord::RecordInvalid) do + PlaidAccount.create!( + plaid_item: @item_a, + plaid_id: "duplicate_plaid", + name: "Checking", + plaid_type: "depository", + currency: "USD", + current_balance: 1000 + ) + end + end +end diff --git a/test/models/provider/binance_test.rb b/test/models/provider/binance_test.rb new file mode 100644 index 000000000..3a502db89 --- /dev/null +++ b/test/models/provider/binance_test.rb @@ -0,0 +1,62 @@ +require "test_helper" + +class Provider::BinanceTest < ActiveSupport::TestCase + setup do + @provider = Provider::Binance.new(api_key: "test_key", api_secret: "test_secret") + end + + test "sign produces HMAC-SHA256 hex digest" do + params = { "timestamp" => "1000", "recvWindow" => "5000" } + sig = @provider.send(:sign, params) + expected = OpenSSL::HMAC.hexdigest("sha256", "test_secret", "recvWindow=5000×tamp=1000") + assert_equal expected, sig + end + + test "auth_headers include X-MBX-APIKEY" do + headers = @provider.send(:auth_headers) + assert_equal "test_key", headers["X-MBX-APIKEY"] + end + + test "timestamp_params returns hash with timestamp and recvWindow" do + params = @provider.send(:timestamp_params) + assert params["timestamp"].present? + assert_in_delta Time.current.to_i * 1000, params["timestamp"].to_i, 5000 + assert_equal "5000", params["recvWindow"] + end + + test "handle_response raises AuthenticationError on 401" do + response = mock_httparty_response(401, { "msg" => "Invalid API-key" }) + assert_raises(Provider::Binance::AuthenticationError) do + @provider.send(:handle_response, response) + end + end + + test "handle_response raises RateLimitError on 429" do + response = mock_httparty_response(429, {}) + assert_raises(Provider::Binance::RateLimitError) do + @provider.send(:handle_response, response) + end + end + + test "handle_response raises ApiError on other non-2xx" do + response = mock_httparty_response(403, { "msg" => "WAF Limit" }) + assert_raises(Provider::Binance::ApiError) do + @provider.send(:handle_response, response) + end + end + + test "handle_response returns parsed body on 200" do + response = mock_httparty_response(200, { "balances" => [] }) + result = @provider.send(:handle_response, response) + assert_equal({ "balances" => [] }, result) + end + + private + + def mock_httparty_response(code, body) + response = mock + response.stubs(:code).returns(code) + response.stubs(:parsed_response).returns(body) + response + end +end diff --git a/test/models/provider/yahoo_finance_test.rb b/test/models/provider/yahoo_finance_test.rb index e7a569b65..6dd0d30a2 100644 --- a/test/models/provider/yahoo_finance_test.rb +++ b/test/models/provider/yahoo_finance_test.rb @@ -10,24 +10,42 @@ class Provider::YahooFinanceTest < ActiveSupport::TestCase # ================================ test "healthy? returns true when API is working" do - # Mock successful response mock_response = mock mock_response.stubs(:body).returns('{"chart":{"result":[{"meta":{"symbol":"AAPL"}}]}}') - @provider.stubs(:client).returns(mock_client = mock) + @provider.stubs(:fetch_cookie_and_crumb).returns([ "test_cookie", "test_crumb" ]) + @provider.stubs(:authenticated_client).returns(mock_client = mock) mock_client.stubs(:get).returns(mock_response) assert @provider.healthy? end test "healthy? returns false when API fails" do - # Mock failed response - @provider.stubs(:client).returns(mock_client = mock) - mock_client.stubs(:get).raises(Faraday::Error.new("Connection failed")) + @provider.stubs(:fetch_cookie_and_crumb).raises(Provider::YahooFinance::AuthenticationError.new("auth failed")) assert_not @provider.healthy? end + test "healthy? retries with fresh crumb on Unauthorized body response" do + unauthorized_body = '{"chart":{"error":{"code":"Unauthorized","description":"No crumb"}}}' + success_body = '{"chart":{"result":[{"meta":{"symbol":"AAPL"}}]}}' + + unauthorized_response = mock + unauthorized_response.stubs(:body).returns(unauthorized_body) + + success_response = mock + success_response.stubs(:body).returns(success_body) + + mock_client = mock + mock_client.stubs(:get).returns(unauthorized_response, success_response) + + @provider.stubs(:fetch_cookie_and_crumb).returns([ "cookie1", "crumb1" ], [ "cookie2", "crumb2" ]) + @provider.stubs(:authenticated_client).returns(mock_client) + @provider.expects(:clear_crumb_cache).once + + assert @provider.healthy? + end + # ================================ # Exchange Rate Tests # ================================ diff --git a/test/models/provider_merchant/enhancer_test.rb b/test/models/provider_merchant/enhancer_test.rb new file mode 100644 index 000000000..e3d717a38 --- /dev/null +++ b/test/models/provider_merchant/enhancer_test.rb @@ -0,0 +1,103 @@ +require "test_helper" + +class ProviderMerchant::EnhancerTest < ActiveSupport::TestCase + include EntriesTestHelper, ProviderTestHelper + + setup do + @family = families(:dylan_family) + @account = @family.accounts.create!(name: "Enhancer test", balance: 100, currency: "USD", accountable: Depository.new) + @llm_provider = mock + Provider::Registry.stubs(:get_provider).with(:openai).returns(@llm_provider) + Setting.stubs(:brand_fetch_client_id).returns("test_client_id") + Setting.stubs(:brand_fetch_logo_size).returns(40) + end + + test "enhances provider merchants with website and logo" do + merchant = ProviderMerchant.create!(source: "lunchflow", name: "Walmart", provider_merchant_id: "lf_walmart") + create_transaction(account: @account, name: "Walmart purchase", merchant: merchant) + + provider_response = provider_success_response([ + EnhancedMerchant.new(merchant_id: merchant.id, business_url: "walmart.com") + ]) + + @llm_provider.expects(:enhance_provider_merchants).returns(provider_response).once + + result = ProviderMerchant::Enhancer.new(@family).enhance + + assert_equal 1, result[:enhanced] + assert_equal "walmart.com", merchant.reload.website_url + assert_equal "https://cdn.brandfetch.io/walmart.com/icon/fallback/lettermark/w/40/h/40?c=test_client_id", merchant.logo_url + end + + test "skips merchants when LLM returns null" do + merchant = ProviderMerchant.create!(source: "lunchflow", name: "Local Diner", provider_merchant_id: "lf_local") + create_transaction(account: @account, name: "Local diner", merchant: merchant) + + provider_response = provider_success_response([ + EnhancedMerchant.new(merchant_id: merchant.id, business_url: nil) + ]) + + @llm_provider.expects(:enhance_provider_merchants).returns(provider_response).once + + result = ProviderMerchant::Enhancer.new(@family).enhance + + assert_equal 0, result[:enhanced] + assert_nil merchant.reload.website_url + end + + test "deduplicates merchants by website_url" do + lunchflow_merchant = ProviderMerchant.create!(source: "lunchflow", name: "Walmart", provider_merchant_id: "lf_walmart") + ai_merchant = ProviderMerchant.create!(source: "ai", name: "Walmart", website_url: "walmart.com", + logo_url: "https://cdn.brandfetch.io/walmart.com/icon/fallback/lettermark/w/40/h/40?c=test_client_id") + + txn1 = create_transaction(account: @account, name: "Walmart purchase 1", merchant: lunchflow_merchant).transaction + txn2 = create_transaction(account: @account, name: "Walmart purchase 2", merchant: ai_merchant).transaction + + provider_response = provider_success_response([ + EnhancedMerchant.new(merchant_id: lunchflow_merchant.id, business_url: "walmart.com") + ]) + + @llm_provider.expects(:enhance_provider_merchants).returns(provider_response).once + + result = ProviderMerchant::Enhancer.new(@family).enhance + + assert_equal 1, result[:enhanced] + assert_equal 1, result[:deduplicated] + assert_equal "walmart.com", lunchflow_merchant.reload.website_url + + # AI merchant's transactions should be reassigned to the lunchflow merchant + assert_equal lunchflow_merchant.id, txn2.reload.merchant_id + assert_equal lunchflow_merchant.id, txn1.reload.merchant_id + end + + test "returns zero counts when no LLM provider" do + Provider::Registry.stubs(:get_provider).with(:openai).returns(nil) + + result = ProviderMerchant::Enhancer.new(@family).enhance + + assert_equal 0, result[:enhanced] + assert_equal 0, result[:deduplicated] + end + + test "returns zero counts when no unenhanced merchants" do + result = ProviderMerchant::Enhancer.new(@family).enhance + + assert_equal 0, result[:enhanced] + assert_equal 0, result[:deduplicated] + end + + test "skips merchants that already have website_url" do + merchant = ProviderMerchant.create!(source: "lunchflow", name: "Amazon", provider_merchant_id: "lf_amazon", website_url: "amazon.com") + create_transaction(account: @account, name: "Amazon order", merchant: merchant) + + # Should not call LLM because no merchants need enhancement + @llm_provider.expects(:enhance_provider_merchants).never + + result = ProviderMerchant::Enhancer.new(@family).enhance + + assert_equal 0, result[:enhanced] + end + + private + EnhancedMerchant = Provider::LlmConcept::EnhancedMerchant +end diff --git a/test/models/qif_import_test.rb b/test/models/qif_import_test.rb new file mode 100644 index 000000000..e370b42be --- /dev/null +++ b/test/models/qif_import_test.rb @@ -0,0 +1,1055 @@ +require "test_helper" + +class QifImportTest < ActiveSupport::TestCase + # ── QifParser unit tests ──────────────────────────────────────────────────── + + SAMPLE_QIF = <<~QIF + !Type:Tag + NTRIP2025 + ^ + NVACATION2023 + DSummer Vacation 2023 + ^ + !Type:Cat + NFood & Dining + DFood and dining expenses + E + ^ + NFood & Dining:Restaurants + DRestaurants + E + ^ + NSalary + DSalary Income + I + ^ + !Type:CCard + D6/ 4'20 + U-99.00 + T-99.00 + C* + NTXFR + PMerchant A + LFees & Charges + ^ + D3/29'21 + U-28,500.00 + T-28,500.00 + PTransfer Out + L[Savings Account] + ^ + D10/ 1'20 + U500.00 + T500.00 + PPayment Received + LFood & Dining/TRIP2025 + ^ + QIF + + QIF_WITH_HIERARCHICAL_CATEGORIES = <<~QIF + !Type:Bank + D1/ 1'24 + U-150.00 + T-150.00 + PHardware Store + LHome:Home Improvement + ^ + D2/ 1'24 + U-50.00 + T-50.00 + PGrocery Store + LFood:Groceries + ^ + QIF + + # A QIF file that includes an Opening Balance entry as the first transaction. + # This mirrors how Quicken exports bank accounts. + QIF_WITH_OPENING_BALANCE = <<~QIF + !Type:Bank + D1/ 1'20 + U500.00 + T500.00 + POpening Balance + L[Checking Account] + ^ + D3/ 1'20 + U100.00 + T100.00 + PFirst Deposit + ^ + D4/ 1'20 + U-25.00 + T-25.00 + PCoffee Shop + ^ + QIF + + # A minimal investment QIF with two securities, trades, a dividend, and a cash transfer. + SAMPLE_INVST_QIF = <<~QIF + !Type:Security + NACME + SACME + TStock + ^ + !Type:Security + NCORP + SCORP + TStock + ^ + !Type:Invst + D1/17'22 + NDiv + YACME + U190.75 + T190.75 + ^ + D1/17'22 + NBuy + YACME + I66.10 + Q2 + U132.20 + T132.20 + ^ + D1/ 7'22 + NXIn + PMonthly Deposit + U8000.00 + T8000.00 + ^ + D2/ 1'22 + NSell + YCORP + I45.00 + Q3 + U135.00 + T135.00 + ^ + QIF + + # A QIF file that includes split transactions (S/$ fields) with an L field category. + QIF_WITH_SPLITS = <<~QIF + !Type:Cat + NFood & Dining + E + ^ + NHousehold + E + ^ + NUtilities + E + ^ + !Type:Bank + D1/ 1'24 + U-150.00 + T-150.00 + PGrocery & Hardware Store + LFood & Dining + SFood & Dining + $-100.00 + EGroceries + SHousehold + $-50.00 + ESupplies + ^ + D1/ 2'24 + U-75.00 + T-75.00 + PElectric Company + LUtilities + ^ + QIF + + # A QIF file where Quicken uses --Split-- as the L field for split transactions. + QIF_WITH_SPLIT_PLACEHOLDER = <<~QIF + !Type:Bank + D1/ 1'24 + U-100.00 + T-100.00 + PWalmart + L--Split-- + SClothing + $-25.00 + SFood + $-25.00 + SHome Improvement + $-50.00 + ^ + D1/ 2'24 + U-30.00 + T-30.00 + PCoffee Shop + LFood & Dining + ^ + QIF + + # ── QifParser: valid? ─────────────────────────────────────────────────────── + + test "valid? returns true for QIF content" do + assert QifParser.valid?(SAMPLE_QIF) + end + + test "valid? returns false for non-QIF content" do + refute QifParser.valid?("") + refute QifParser.valid?("date,amount,name\n2024-01-01,100,Coffee") + refute QifParser.valid?(nil) + refute QifParser.valid?("") + end + + # ── QifParser: account_type ───────────────────────────────────────────────── + + test "account_type extracts transaction section type" do + assert_equal "CCard", QifParser.account_type(SAMPLE_QIF) + end + + test "account_type ignores Tag and Cat sections" do + qif = "!Type:Tag\nNMyTag\n^\n!Type:Cat\nNMyCat\n^\n!Type:Bank\nD1/1'24\nT100.00\nPTest\n^\n" + assert_equal "Bank", QifParser.account_type(qif) + end + + # ── QifParser: parse (transactions) ───────────────────────────────────────── + + test "parse returns correct number of transactions" do + assert_equal 3, QifParser.parse(SAMPLE_QIF).length + end + + test "parse extracts dates correctly" do + transactions = QifParser.parse(SAMPLE_QIF) + assert_equal "2020-06-04", transactions[0].date + assert_equal "2021-03-29", transactions[1].date + assert_equal "2020-10-01", transactions[2].date + end + + test "parse extracts negative amount with commas" do + assert_equal "-28500.00", QifParser.parse(SAMPLE_QIF)[1].amount + end + + test "parse extracts simple negative amount" do + assert_equal "-99.00", QifParser.parse(SAMPLE_QIF)[0].amount + end + + test "parse extracts payee" do + transactions = QifParser.parse(SAMPLE_QIF) + assert_equal "Merchant A", transactions[0].payee + assert_equal "Transfer Out", transactions[1].payee + end + + test "parse extracts category and ignores transfer accounts" do + transactions = QifParser.parse(SAMPLE_QIF) + assert_equal "Fees & Charges", transactions[0].category + assert_equal "", transactions[1].category # [Savings Account] = transfer + assert_equal "Food & Dining", transactions[2].category + end + + test "parse extracts tags from L field slash suffix" do + transactions = QifParser.parse(SAMPLE_QIF) + assert_equal [], transactions[0].tags + assert_equal [], transactions[1].tags + assert_equal [ "TRIP2025" ], transactions[2].tags + end + + # ── QifParser: parse_categories ───────────────────────────────────────────── + + test "parse_categories returns all categories" do + names = QifParser.parse_categories(SAMPLE_QIF).map(&:name) + assert_includes names, "Food & Dining" + assert_includes names, "Food & Dining:Restaurants" + assert_includes names, "Salary" + end + + test "parse_categories marks income vs expense correctly" do + categories = QifParser.parse_categories(SAMPLE_QIF) + salary = categories.find { |c| c.name == "Salary" } + food = categories.find { |c| c.name == "Food & Dining" } + assert salary.income + refute food.income + end + + # ── QifParser: parse_tags ─────────────────────────────────────────────────── + + test "parse_tags returns all tags" do + names = QifParser.parse_tags(SAMPLE_QIF).map(&:name) + assert_includes names, "TRIP2025" + assert_includes names, "VACATION2023" + end + + test "parse_tags captures description" do + vacation = QifParser.parse_tags(SAMPLE_QIF).find { |t| t.name == "VACATION2023" } + assert_equal "Summer Vacation 2023", vacation.description + end + + # ── QifParser: encoding ────────────────────────────────────────────────────── + + test "normalize_encoding returns content unchanged when already valid UTF-8" do + result = QifParser.normalize_encoding("!Type:CCard\n") + assert_equal "!Type:CCard\n", result + end + + # ── QifParser: opening balance ─────────────────────────────────────────────── + + test "parse skips Opening Balance transaction" do + transactions = QifParser.parse(QIF_WITH_OPENING_BALANCE) + assert_equal 2, transactions.length + refute transactions.any? { |t| t.payee == "Opening Balance" } + end + + test "parse_opening_balance returns date and amount" do + ob = QifParser.parse_opening_balance(QIF_WITH_OPENING_BALANCE) + assert_not_nil ob + assert_equal Date.new(2020, 1, 1), ob[:date] + assert_equal BigDecimal("500"), ob[:amount] + end + + test "parse_opening_balance returns nil when no Opening Balance entry" do + assert_nil QifParser.parse_opening_balance(SAMPLE_QIF) + end + + test "parse_opening_balance returns nil for blank content" do + assert_nil QifParser.parse_opening_balance(nil) + assert_nil QifParser.parse_opening_balance("") + end + + # ── QifParser: split transactions ────────────────────────────────────────── + + test "parse flags split transactions" do + transactions = QifParser.parse(QIF_WITH_SPLITS) + split_txn = transactions.find { |t| t.payee == "Grocery & Hardware Store" } + normal_txn = transactions.find { |t| t.payee == "Electric Company" } + + assert split_txn.split, "Expected split transaction to be flagged" + refute normal_txn.split, "Expected normal transaction not to be flagged" + end + + test "parse returns correct count including split transactions" do + transactions = QifParser.parse(QIF_WITH_SPLITS) + assert_equal 2, transactions.length + end + + test "parse strips --Split-- placeholder from category" do + transactions = QifParser.parse(QIF_WITH_SPLIT_PLACEHOLDER) + walmart = transactions.find { |t| t.payee == "Walmart" } + + assert walmart.split, "Expected split transaction to be flagged" + assert_equal "", walmart.category, "Expected --Split-- to be stripped from category" + end + + test "parse preserves normal category alongside --Split-- placeholder" do + transactions = QifParser.parse(QIF_WITH_SPLIT_PLACEHOLDER) + coffee = transactions.find { |t| t.payee == "Coffee Shop" } + + refute coffee.split + assert_equal "Food & Dining", coffee.category + end + + # ── QifImport model ───────────────────────────────────────────────────────── + + setup do + @family = families(:dylan_family) + @account = accounts(:depository) + @import = QifImport.create!(family: @family, account: @account) + end + + test "generates rows from QIF content" do + @import.update!(raw_file_str: SAMPLE_QIF) + @import.generate_rows_from_csv + + assert_equal 3, @import.rows.count + end + + test "rows_count is updated after generate_rows_from_csv" do + @import.update!(raw_file_str: SAMPLE_QIF) + @import.generate_rows_from_csv + + assert_equal 3, @import.reload.rows_count + end + + test "generates row with correct date and amount" do + @import.update!(raw_file_str: SAMPLE_QIF) + @import.generate_rows_from_csv + + row = @import.rows.find_by(name: "Merchant A") + assert_equal "2020-06-04", row.date + assert_equal "-99.00", row.amount + end + + test "generates row with category" do + @import.update!(raw_file_str: SAMPLE_QIF) + @import.generate_rows_from_csv + + row = @import.rows.find_by(name: "Merchant A") + assert_equal "Fees & Charges", row.category + end + + test "generates row with tags stored as pipe-separated string" do + @import.update!(raw_file_str: SAMPLE_QIF) + @import.generate_rows_from_csv + + row = @import.rows.find_by(name: "Payment Received") + assert_equal "TRIP2025", row.tags + end + + test "transfer rows have blank category" do + @import.update!(raw_file_str: SAMPLE_QIF) + @import.generate_rows_from_csv + + row = @import.rows.find_by(name: "Transfer Out") + assert row.category.blank? + end + + test "requires_csv_workflow? is false" do + refute @import.requires_csv_workflow? + end + + test "qif_account_type returns CCard for credit card QIF" do + @import.update!(raw_file_str: SAMPLE_QIF) + assert_equal "CCard", @import.qif_account_type + end + + test "row_categories excludes blank categories" do + @import.update!(raw_file_str: SAMPLE_QIF) + @import.generate_rows_from_csv + + cats = @import.row_categories + assert_includes cats, "Fees & Charges" + assert_includes cats, "Food & Dining" + refute_includes cats, "" + end + + test "row_tags excludes blank tags" do + @import.update!(raw_file_str: SAMPLE_QIF) + @import.generate_rows_from_csv + + tags = @import.row_tags + assert_includes tags, "TRIP2025" + refute_includes tags, "" + end + + test "split_categories returns categories from split transactions" do + @import.update!(raw_file_str: QIF_WITH_SPLITS) + @import.generate_rows_from_csv + + split_cats = @import.split_categories + assert_includes split_cats, "Food & Dining" + refute_includes split_cats, "Utilities" + end + + test "split_categories returns empty when no splits" do + @import.update!(raw_file_str: SAMPLE_QIF) + @import.generate_rows_from_csv + + assert_empty @import.split_categories + end + + test "has_split_transactions? returns true when splits exist" do + @import.update!(raw_file_str: QIF_WITH_SPLITS) + assert @import.has_split_transactions? + end + + test "has_split_transactions? returns true for --Split-- placeholder" do + @import.update!(raw_file_str: QIF_WITH_SPLIT_PLACEHOLDER) + assert @import.has_split_transactions? + end + + test "has_split_transactions? returns false when no splits" do + @import.update!(raw_file_str: SAMPLE_QIF) + refute @import.has_split_transactions? + end + + test "split_categories is empty when splits use --Split-- placeholder" do + @import.update!(raw_file_str: QIF_WITH_SPLIT_PLACEHOLDER) + @import.generate_rows_from_csv + + assert_empty @import.split_categories + refute_includes @import.row_categories, "--Split--" + end + + test "categories_selected? is false before sync_mappings" do + @import.update!(raw_file_str: SAMPLE_QIF) + @import.generate_rows_from_csv + + refute @import.categories_selected? + end + + test "categories_selected? is true after sync_mappings" do + @import.update!(raw_file_str: SAMPLE_QIF) + @import.generate_rows_from_csv + @import.sync_mappings + + assert @import.categories_selected? + end + + test "publishable? requires account to be present" do + import_without_account = QifImport.create!(family: @family) + import_without_account.update_columns(raw_file_str: SAMPLE_QIF, rows_count: 1) + + refute import_without_account.publishable? + end + + # ── Opening balance handling ───────────────────────────────────────────────── + + test "Opening Balance row is not generated as a transaction row" do + @import.update!(raw_file_str: QIF_WITH_OPENING_BALANCE) + @import.generate_rows_from_csv + + assert_equal 2, @import.rows.count + refute @import.rows.exists?(name: "Opening Balance") + end + + test "import! sets opening anchor from QIF Opening Balance entry" do + @import.update!(raw_file_str: QIF_WITH_OPENING_BALANCE) + @import.generate_rows_from_csv + @import.sync_mappings + @import.import! + + manager = Account::OpeningBalanceManager.new(@account) + assert manager.has_opening_anchor? + assert_equal Date.new(2020, 1, 1), manager.opening_date + assert_equal BigDecimal("500"), manager.opening_balance + end + + test "import! moves opening anchor back when transactions predate it" do + # Anchor set 2 years ago; SAMPLE_QIF has transactions from 2020 which predate it + @account.entries.create!( + date: 2.years.ago.to_date, + name: "Opening balance", + amount: 0, + currency: @account.currency, + entryable: Valuation.new(kind: "opening_anchor") + ) + + @import.update!(raw_file_str: SAMPLE_QIF) + @import.generate_rows_from_csv + @import.sync_mappings + @import.import! + + manager = Account::OpeningBalanceManager.new(@account.reload) + # Day before the earliest SAMPLE_QIF transaction (2020-06-04) + assert_equal Date.new(2020, 6, 3), manager.opening_date + assert_equal 0, manager.opening_balance + end + + test "import! does not move opening anchor when transactions do not predate it" do + anchor_date = Date.new(2020, 1, 1) # before the earliest SAMPLE_QIF transaction (2020-06-04) + @account.entries.create!( + date: anchor_date, + name: "Opening balance", + amount: 0, + currency: @account.currency, + entryable: Valuation.new(kind: "opening_anchor") + ) + + @import.update!(raw_file_str: SAMPLE_QIF) + @import.generate_rows_from_csv + @import.sync_mappings + @import.import! + + assert_equal anchor_date, Account::OpeningBalanceManager.new(@account.reload).opening_date + end + + test "import! updates a pre-existing opening anchor from QIF Opening Balance entry" do + @account.entries.create!( + date: 2.years.ago.to_date, + name: "Opening balance", + amount: 0, + currency: @account.currency, + entryable: Valuation.new(kind: "opening_anchor") + ) + + @import.update!(raw_file_str: QIF_WITH_OPENING_BALANCE) + @import.generate_rows_from_csv + @import.sync_mappings + @import.import! + + manager = Account::OpeningBalanceManager.new(@account.reload) + assert_equal Date.new(2020, 1, 1), manager.opening_date + assert_equal BigDecimal("500"), manager.opening_balance + end + + test "will_adjust_opening_anchor? returns true when transactions predate anchor" do + @account.entries.create!( + date: 2.years.ago.to_date, + name: "Opening balance", + amount: 0, + currency: @account.currency, + entryable: Valuation.new(kind: "opening_anchor") + ) + + @import.update!(raw_file_str: SAMPLE_QIF) + @import.generate_rows_from_csv + + assert @import.will_adjust_opening_anchor? + end + + test "will_adjust_opening_anchor? returns false when QIF has Opening Balance entry" do + @account.entries.create!( + date: 2.years.ago.to_date, + name: "Opening balance", + amount: 0, + currency: @account.currency, + entryable: Valuation.new(kind: "opening_anchor") + ) + + @import.update!(raw_file_str: QIF_WITH_OPENING_BALANCE) + @import.generate_rows_from_csv + + refute @import.will_adjust_opening_anchor? + end + + test "adjusted_opening_anchor_date is one day before earliest transaction" do + @import.update!(raw_file_str: SAMPLE_QIF) + @import.generate_rows_from_csv + + assert_equal Date.new(2020, 6, 3), @import.adjusted_opening_anchor_date + end + + # ── Hierarchical category (Parent:Child) ───────────────────────────────────── + + test "generates rows with hierarchical category stored as-is" do + @import.update!(raw_file_str: QIF_WITH_HIERARCHICAL_CATEGORIES) + @import.generate_rows_from_csv + + row = @import.rows.find_by(name: "Hardware Store") + assert_equal "Home:Home Improvement", row.category + end + + test "create_mappable! creates parent and child categories for hierarchical key" do + @import.update!(raw_file_str: QIF_WITH_HIERARCHICAL_CATEGORIES) + @import.generate_rows_from_csv + @import.sync_mappings + + mapping = @import.mappings.categories.find_by(key: "Home:Home Improvement") + mapping.update!(create_when_empty: true) + mapping.create_mappable! + + child = @family.categories.find_by(name: "Home Improvement") + assert_not_nil child + assert_not_nil child.parent + assert_equal "Home", child.parent.name + end + + test "create_mappable! reuses existing parent category for hierarchical key" do + existing_parent = @family.categories.create!( + name: "Home", color: "#aabbcc", lucide_icon: "house" + ) + + @import.update!(raw_file_str: QIF_WITH_HIERARCHICAL_CATEGORIES) + @import.generate_rows_from_csv + @import.sync_mappings + + mapping = @import.mappings.categories.find_by(key: "Home:Home Improvement") + mapping.update!(create_when_empty: true) + + assert_no_difference "@family.categories.where(name: 'Home').count" do + mapping.create_mappable! + end + + child = @family.categories.find_by(name: "Home Improvement") + assert_equal existing_parent.id, child.parent_id + end + + test "mappables_by_key pre-matches hierarchical key to existing child category" do + parent = @family.categories.create!( + name: "Home", color: "#aabbcc", lucide_icon: "house" + ) + child = @family.categories.create!( + name: "Home Improvement", color: "#aabbcc", lucide_icon: "house", + parent: parent + ) + + @import.update!(raw_file_str: QIF_WITH_HIERARCHICAL_CATEGORIES) + @import.generate_rows_from_csv + + mappables = Import::CategoryMapping.mappables_by_key(@import) + assert_equal child, mappables["Home:Home Improvement"] + end + + # ── Investment (Invst) QIF: parser ────────────────────────────────────────── + + test "parse_securities returns all securities from investment QIF" do + securities = QifParser.parse_securities(SAMPLE_INVST_QIF) + assert_equal 2, securities.length + tickers = securities.map(&:ticker) + assert_includes tickers, "ACME" + assert_includes tickers, "CORP" + end + + test "parse_securities maps name to ticker and type correctly" do + acme = QifParser.parse_securities(SAMPLE_INVST_QIF).find { |s| s.ticker == "ACME" } + assert_equal "ACME", acme.name + assert_equal "Stock", acme.security_type + end + + test "parse_securities returns empty array for non-investment QIF" do + assert_empty QifParser.parse_securities(SAMPLE_QIF) + end + + test "parse_investment_transactions returns all investment records" do + assert_equal 4, QifParser.parse_investment_transactions(SAMPLE_INVST_QIF).length + end + + test "parse_investment_transactions resolves security name to ticker" do + buy = QifParser.parse_investment_transactions(SAMPLE_INVST_QIF).find { |t| t.action == "Buy" } + assert_equal "ACME", buy.security_ticker + assert_equal "ACME", buy.security_name + end + + test "parse_investment_transactions extracts price, qty, and amount for trade actions" do + buy = QifParser.parse_investment_transactions(SAMPLE_INVST_QIF).find { |t| t.action == "Buy" } + assert_equal "66.10", buy.price + assert_equal "2", buy.qty + assert_equal "132.20", buy.amount + end + + test "parse_investment_transactions extracts amount and ticker for dividend" do + div = QifParser.parse_investment_transactions(SAMPLE_INVST_QIF).find { |t| t.action == "Div" } + assert_equal "190.75", div.amount + assert_equal "ACME", div.security_ticker + end + + test "parse_investment_transactions extracts payee for cash actions" do + xin = QifParser.parse_investment_transactions(SAMPLE_INVST_QIF).find { |t| t.action == "XIn" } + assert_equal "Monthly Deposit", xin.payee + assert_equal "8000.00", xin.amount + end + + # ── Investment (Invst) QIF: row generation ────────────────────────────────── + + test "qif_account_type returns Invst for investment QIF" do + @import.update!(raw_file_str: SAMPLE_INVST_QIF) + assert_equal "Invst", @import.qif_account_type + end + + test "generates correct number of rows from investment QIF" do + @import.update!(raw_file_str: SAMPLE_INVST_QIF) + @import.generate_rows_from_csv + + assert_equal 4, @import.rows.count + end + + test "generates trade rows with correct entity_type, ticker, qty, and price" do + @import.update!(raw_file_str: SAMPLE_INVST_QIF) + @import.generate_rows_from_csv + + buy_row = @import.rows.find_by(entity_type: "Buy") + assert_not_nil buy_row + assert_equal "ACME", buy_row.ticker + assert_equal "2.0", buy_row.qty + assert_equal "66.10", buy_row.price + assert_equal "132.20", buy_row.amount + end + + test "generates sell row with negative qty" do + @import.update!(raw_file_str: SAMPLE_INVST_QIF) + @import.generate_rows_from_csv + + sell_row = @import.rows.find_by(entity_type: "Sell") + assert_not_nil sell_row + assert_equal "CORP", sell_row.ticker + assert_equal "-3.0", sell_row.qty + end + + test "generates transaction row for Div with security name in row name" do + @import.update!(raw_file_str: SAMPLE_INVST_QIF) + @import.generate_rows_from_csv + + div_row = @import.rows.find_by(entity_type: "Div") + assert_not_nil div_row + assert_equal "Dividend: ACME", div_row.name + assert_equal "190.75", div_row.amount + end + + test "generates transaction row for XIn using payee as name" do + @import.update!(raw_file_str: SAMPLE_INVST_QIF) + @import.generate_rows_from_csv + + xin_row = @import.rows.find_by(entity_type: "XIn") + assert_not_nil xin_row + assert_equal "Monthly Deposit", xin_row.name + end + + # ── Investment (Invst) QIF: import! ───────────────────────────────────────── + + test "import! creates Trade records for buy and sell rows" do + import = QifImport.create!(family: @family, account: accounts(:investment)) + import.update!(raw_file_str: SAMPLE_INVST_QIF) + import.generate_rows_from_csv + import.sync_mappings + + Security::Resolver.any_instance.stubs(:resolve).returns(securities(:aapl)) + + assert_difference "Trade.count", 2 do + import.import! + end + end + + test "import! creates Transaction records for dividend and cash rows" do + import = QifImport.create!(family: @family, account: accounts(:investment)) + import.update!(raw_file_str: SAMPLE_INVST_QIF) + import.generate_rows_from_csv + import.sync_mappings + + Security::Resolver.any_instance.stubs(:resolve).returns(securities(:aapl)) + + assert_difference "Transaction.count", 2 do + import.import! + end + end + + test "import! creates inflow Entry for Div (negative amount)" do + import = QifImport.create!(family: @family, account: accounts(:investment)) + import.update!(raw_file_str: SAMPLE_INVST_QIF) + import.generate_rows_from_csv + import.sync_mappings + + Security::Resolver.any_instance.stubs(:resolve).returns(securities(:aapl)) + import.import! + + div_entry = accounts(:investment).entries.find_by(name: "Dividend: ACME") + assert_not_nil div_entry + assert div_entry.amount.negative?, "Dividend should be an inflow (negative amount)" + assert_in_delta(-190.75, div_entry.amount, 0.01) + end + + test "import! creates outflow Entry for Buy (positive amount)" do + import = QifImport.create!(family: @family, account: accounts(:investment)) + import.update!(raw_file_str: SAMPLE_INVST_QIF) + import.generate_rows_from_csv + import.sync_mappings + + Security::Resolver.any_instance.stubs(:resolve).returns(securities(:aapl)) + import.import! + + buy_entry = accounts(:investment) + .entries + .joins("INNER JOIN trades ON trades.id = entries.entryable_id AND entries.entryable_type = 'Trade'") + .find_by("trades.qty > 0") + assert_not_nil buy_entry + assert buy_entry.amount.positive?, "Buy trade should be an outflow (positive amount)" + end + + test "import! creates inflow Entry for Sell (negative amount)" do + import = QifImport.create!(family: @family, account: accounts(:investment)) + import.update!(raw_file_str: SAMPLE_INVST_QIF) + import.generate_rows_from_csv + import.sync_mappings + + Security::Resolver.any_instance.stubs(:resolve).returns(securities(:aapl)) + import.import! + + sell_entry = accounts(:investment) + .entries + .joins("INNER JOIN trades ON trades.id = entries.entryable_id AND entries.entryable_type = 'Trade'") + .find_by("trades.qty < 0") + assert_not_nil sell_entry + assert sell_entry.amount.negative?, "Sell trade should be an inflow (negative amount)" + end + + test "will_adjust_opening_anchor? returns false for investment accounts" do + import = QifImport.create!(family: @family, account: accounts(:investment)) + import.update!(raw_file_str: SAMPLE_INVST_QIF) + import.generate_rows_from_csv + + refute import.will_adjust_opening_anchor? + end + + # ── QifParser: normalize_qif_date ────────────────────────────────────────── + + test "normalize_qif_date converts apostrophe 2-digit year" do + assert_equal "6/4/2020", QifParser.send(:normalize_qif_date, "6/ 4'20") + end + + test "normalize_qif_date converts apostrophe 4-digit year" do + assert_equal "6/4/2020", QifParser.send(:normalize_qif_date, "6/ 4'2020") + end + + test "normalize_qif_date handles dot-separated dates" do + assert_equal "04.06.2020", QifParser.send(:normalize_qif_date, "04.06.2020") + end + + test "normalize_qif_date handles dot with apostrophe year" do + assert_equal "04.06.2020", QifParser.send(:normalize_qif_date, "04.06'20") + end + + test "normalize_qif_date handles dash-separated dates" do + assert_equal "2020-06-04", QifParser.send(:normalize_qif_date, "2020-06-04") + end + + test "normalize_qif_date returns nil for blank input" do + assert_nil QifParser.send(:normalize_qif_date, nil) + assert_nil QifParser.send(:normalize_qif_date, "") + end + + # ── QifParser: parse_qif_date with different formats ─────────────────────── + + test "parse_qif_date parses US format (MM/DD/YYYY)" do + assert_equal "2020-06-04", QifParser.send(:parse_qif_date, "6/ 4'20", date_format: "%m/%d/%Y") + end + + test "parse_qif_date parses European slash format (DD/MM/YYYY)" do + # 4/ 6'20 → day=4, month=6 → June 4th + assert_equal "2020-06-04", QifParser.send(:parse_qif_date, "4/ 6'20", date_format: "%d/%m/%Y") + end + + test "parse_qif_date parses European dot format (DD.MM.YYYY)" do + assert_equal "2020-06-04", QifParser.send(:parse_qif_date, "04.06.2020", date_format: "%d.%m.%Y") + end + + test "parse_qif_date parses ISO format (YYYY-MM-DD)" do + assert_equal "2020-06-04", QifParser.send(:parse_qif_date, "2020-06-04", date_format: "%Y-%m-%d") + end + + test "parse_qif_date returns nil for invalid date" do + assert_nil QifParser.send(:parse_qif_date, "13/32/2020", date_format: "%m/%d/%Y") + end + + # ── QifParser: extract_raw_dates ─────────────────────────────────────────── + + test "extract_raw_dates returns normalized date strings from D-fields" do + dates = QifParser.extract_raw_dates(SAMPLE_QIF) + assert_includes dates, "6/4/2020" + assert_includes dates, "3/29/2021" + assert_includes dates, "10/1/2020" + end + + test "extract_raw_dates returns empty for blank content" do + assert_empty QifParser.extract_raw_dates(nil) + assert_empty QifParser.extract_raw_dates("") + end + + # ── QifParser: parse with European date format ───────────────────────────── + + EUROPEAN_QIF = <<~QIF + !Type:Bank + D04/06/2020 + U-99.00 + T-99.00 + PMerchant A + ^ + D29/03/2021 + U-50.00 + T-50.00 + PMerchant B + ^ + QIF + + test "parse with DD/MM/YYYY format parses dates correctly" do + transactions = QifParser.parse(EUROPEAN_QIF, date_format: "%d/%m/%Y") + assert_equal "2020-06-04", transactions[0].date + assert_equal "2021-03-29", transactions[1].date + end + + # ── Import.detect_date_format ────────────────────────────────────────────── + + test "detect_date_format identifies US slash format" do + samples = %w[6/4/2020 3/29/2021 10/1/2020] + # 3/29 cannot be DD/MM (month 29 invalid), so must be MM/DD + assert_equal "%m/%d/%Y", Import.detect_date_format(samples) + end + + test "detect_date_format identifies European slash format" do + samples = %w[04/06/2020 29/03/2021 01/10/2020] + # 29/03 cannot be MM/DD (month 29 invalid), so must be DD/MM + assert_equal "%d/%m/%Y", Import.detect_date_format(samples) + end + + test "detect_date_format identifies European dot format" do + samples = %w[04.06.2020 29.03.2021 01.10.2020] + assert_equal "%d.%m.%Y", Import.detect_date_format(samples) + end + + test "detect_date_format identifies ISO format" do + samples = %w[2020-06-04 2021-03-29 2020-10-01] + assert_equal "%Y-%m-%d", Import.detect_date_format(samples) + end + + test "detect_date_format returns fallback for blank samples" do + assert_equal "%Y-%m-%d", Import.detect_date_format([]) + assert_equal "%Y-%m-%d", Import.detect_date_format(nil) + end + + test "detect_date_format returns fallback when no format matches" do + samples = %w[not-a-date garbage] + assert_equal "%Y-%m-%d", Import.detect_date_format(samples) + end + + # ── QifImport: auto-detection integration ────────────────────────────────── + + test "generate_rows_from_csv auto-detects US date format" do + @import.update!(raw_file_str: SAMPLE_QIF) + @import.generate_rows_from_csv + + assert_equal "%m/%d/%Y", @import.reload.qif_date_format + row = @import.rows.find_by(name: "Merchant A") + assert_equal "2020-06-04", row.date + end + + EUROPEAN_BANK_QIF = <<~QIF + !Type:Bank + D13/01/2024 + U-100.00 + T-100.00 + PEuropean Store + ^ + D25/12/2023 + U-50.00 + T-50.00 + PChristmas Shop + ^ + QIF + + test "generate_rows_from_csv auto-detects European DD/MM/YYYY format" do + @import.update!(raw_file_str: EUROPEAN_BANK_QIF) + @import.generate_rows_from_csv + + assert_equal "%d/%m/%Y", @import.reload.qif_date_format + row = @import.rows.find_by(name: "European Store") + assert_equal "2024-01-13", row.date + end + + test "generate_rows_from_csv respects manually set qif_date_format" do + @import.update!(raw_file_str: EUROPEAN_BANK_QIF) + @import.qif_date_format = "%d/%m/%Y" + @import.save!(validate: false) + @import.generate_rows_from_csv + + # Should not re-detect since qif_date_format is already set + assert_equal "%d/%m/%Y", @import.reload.qif_date_format + end + + # ── QifParser: try_parse_date ─────────────────────────────────────────────── + + test "try_parse_date returns ISO date for valid format" do + assert_equal "2020-06-04", QifParser.try_parse_date("6/ 4'20", date_format: "%m/%d/%Y") + end + + test "try_parse_date returns nil for incompatible format" do + assert_nil QifParser.try_parse_date("2020-06-04", date_format: "%d.%m.%Y") + end + + # ── QifImport: valid_date_formats_with_preview ────────────────────────────── + + test "valid_date_formats_with_preview excludes formats that cannot parse the file dates" do + @import.update!(raw_file_str: EUROPEAN_BANK_QIF) + formats = @import.valid_date_formats_with_preview + + format_strs = formats.map { |f| f[:format] } + + # DD/MM/YYYY should be valid (13/01/2024) + assert_includes format_strs, "%d/%m/%Y" + + # MM/DD/YYYY should be excluded (month 13 is invalid) + assert_not_includes format_strs, "%m/%d/%Y" + + # Each valid format should have a preview date + formats.each do |f| + assert_not_nil f[:preview], "Expected preview for #{f[:label]}" + end + end + + test "valid_date_formats_with_preview returns empty array when no raw dates" do + @import.update!(raw_file_str: "") + formats = @import.valid_date_formats_with_preview + + assert_empty formats + end +end diff --git a/test/models/recurring_transaction/identifier_test.rb b/test/models/recurring_transaction/identifier_test.rb index d7a7f93be..2b0149886 100644 --- a/test/models/recurring_transaction/identifier_test.rb +++ b/test/models/recurring_transaction/identifier_test.rb @@ -172,6 +172,7 @@ class RecurringTransaction::IdentifierTest < ActiveSupport::TestCase # Create initial recurring transaction existing = @family.recurring_transactions.create!( + account: account, merchant: merchant, amount: 29.99, currency: "USD", diff --git a/test/models/recurring_transaction_test.rb b/test/models/recurring_transaction_test.rb index a7f638c3c..7b1b506f6 100644 --- a/test/models/recurring_transaction_test.rb +++ b/test/models/recurring_transaction_test.rb @@ -4,6 +4,7 @@ class RecurringTransactionTest < ActiveSupport::TestCase def setup @family = families(:dylan_family) @merchant = merchants(:netflix) + @account = accounts(:depository) # Clear any existing recurring transactions @family.recurring_transactions.destroy_all end @@ -11,13 +12,12 @@ class RecurringTransactionTest < ActiveSupport::TestCase test "identify_patterns_for creates recurring transactions for patterns with 3+ occurrences" do # Create a series of transactions with same merchant and amount on similar days # Use dates within the last 3 months: today, 1 month ago, 2 months ago - account = @family.accounts.first [ 0, 1, 2 ].each do |months_ago| transaction = Transaction.create!( merchant: @merchant, category: categories(:food_and_drink) ) - account.entries.create!( + @account.entries.create!( date: months_ago.months.ago.beginning_of_month + 5.days, amount: 15.99, currency: "USD", @@ -32,6 +32,7 @@ class RecurringTransactionTest < ActiveSupport::TestCase recurring = @family.recurring_transactions.last assert_equal @merchant, recurring.merchant + assert_equal @account, recurring.account assert_equal 15.99, recurring.amount assert_equal "USD", recurring.currency assert_equal "active", recurring.status @@ -40,13 +41,12 @@ class RecurringTransactionTest < ActiveSupport::TestCase test "identify_patterns_for does not create recurring transaction for less than 3 occurrences" do # Create only 2 transactions - account = @family.accounts.first 2.times do |i| transaction = Transaction.create!( merchant: @merchant, category: categories(:food_and_drink) ) - account.entries.create!( + @account.entries.create!( date: (i + 1).months.ago.beginning_of_month + 5.days, amount: 15.99, currency: "USD", @@ -62,6 +62,7 @@ class RecurringTransactionTest < ActiveSupport::TestCase test "calculate_next_expected_date handles end of month correctly" do recurring = @family.recurring_transactions.create!( + account: @account, merchant: @merchant, amount: 29.99, currency: "USD", @@ -78,6 +79,7 @@ class RecurringTransactionTest < ActiveSupport::TestCase test "should_be_inactive? returns true when last occurrence is over 2 months ago" do recurring = @family.recurring_transactions.create!( + account: @account, merchant: merchants(:amazon), amount: 19.99, currency: "USD", @@ -92,6 +94,7 @@ class RecurringTransactionTest < ActiveSupport::TestCase test "should_be_inactive? returns false when last occurrence is within 2 months" do recurring = @family.recurring_transactions.create!( + account: @account, merchant: merchants(:amazon), amount: 25.99, currency: "USD", @@ -106,6 +109,7 @@ class RecurringTransactionTest < ActiveSupport::TestCase test "cleanup_stale_for marks inactive when no recent occurrences" do recurring = @family.recurring_transactions.create!( + account: @account, merchant: merchants(:amazon), amount: 35.99, currency: "USD", @@ -122,6 +126,7 @@ class RecurringTransactionTest < ActiveSupport::TestCase test "record_occurrence! updates recurring transaction with new occurrence" do recurring = @family.recurring_transactions.create!( + account: @account, merchant: merchants(:amazon), amount: 45.99, currency: "USD", @@ -143,13 +148,12 @@ class RecurringTransactionTest < ActiveSupport::TestCase test "identify_patterns_for preserves sign for income transactions" do # Create recurring income transactions (negative amounts) - account = @family.accounts.first [ 0, 1, 2 ].each do |months_ago| transaction = Transaction.create!( merchant: @merchant, category: categories(:income) ) - account.entries.create!( + @account.entries.create!( date: months_ago.months.ago.beginning_of_month + 15.days, amount: -1000.00, currency: "USD", @@ -164,6 +168,7 @@ class RecurringTransactionTest < ActiveSupport::TestCase recurring = @family.recurring_transactions.last assert_equal @merchant, recurring.merchant + assert_equal @account, recurring.account assert_equal(-1000.00, recurring.amount) assert recurring.amount.negative?, "Income should have negative amount" assert_equal "USD", recurring.currency @@ -172,12 +177,11 @@ class RecurringTransactionTest < ActiveSupport::TestCase test "identify_patterns_for creates name-based recurring transactions for transactions without merchants" do # Create transactions without merchants (e.g., from CSV imports or standard accounts) - account = @family.accounts.first [ 0, 1, 2 ].each do |months_ago| transaction = Transaction.create!( category: categories(:food_and_drink) ) - account.entries.create!( + @account.entries.create!( date: months_ago.months.ago.beginning_of_month + 10.days, amount: 25.00, currency: "USD", @@ -192,6 +196,7 @@ class RecurringTransactionTest < ActiveSupport::TestCase recurring = @family.recurring_transactions.last assert_nil recurring.merchant + assert_equal @account, recurring.account assert_equal "Local Coffee Shop", recurring.name assert_equal 25.00, recurring.amount assert_equal "USD", recurring.currency @@ -201,7 +206,6 @@ class RecurringTransactionTest < ActiveSupport::TestCase test "identify_patterns_for creates separate patterns for same merchant but different names" do # Create two different recurring transactions from the same merchant - account = @family.accounts.first # First pattern: Netflix Standard [ 0, 1, 2 ].each do |months_ago| @@ -209,7 +213,7 @@ class RecurringTransactionTest < ActiveSupport::TestCase merchant: @merchant, category: categories(:food_and_drink) ) - account.entries.create!( + @account.entries.create!( date: months_ago.months.ago.beginning_of_month + 5.days, amount: 15.99, currency: "USD", @@ -224,7 +228,7 @@ class RecurringTransactionTest < ActiveSupport::TestCase merchant: @merchant, category: categories(:food_and_drink) ) - account.entries.create!( + @account.entries.create!( date: months_ago.months.ago.beginning_of_month + 10.days, amount: 19.99, currency: "USD", @@ -245,14 +249,12 @@ class RecurringTransactionTest < ActiveSupport::TestCase skip "merchant_id is NOT NULL in this schema; name-based patterns disabled" end - account = @family.accounts.first - # Create transactions for pattern [ 0, 1, 2 ].each do |months_ago| transaction = Transaction.create!( category: categories(:food_and_drink) ) - account.entries.create!( + @account.entries.create!( date: months_ago.months.ago.beginning_of_month + 15.days, amount: 50.00, currency: "USD", @@ -272,6 +274,7 @@ class RecurringTransactionTest < ActiveSupport::TestCase test "validation requires either merchant or name" do recurring = @family.recurring_transactions.build( + account: @account, amount: 25.00, currency: "USD", expected_day_of_month: 5, @@ -288,7 +291,6 @@ class RecurringTransactionTest < ActiveSupport::TestCase unless RecurringTransaction.columns_hash["merchant_id"].null skip "merchant_id is NOT NULL in this schema; name-based patterns disabled" end - account = @family.accounts.first # Create merchant-based pattern [ 0, 1, 2 ].each do |months_ago| @@ -296,7 +298,7 @@ class RecurringTransactionTest < ActiveSupport::TestCase merchant: @merchant, category: categories(:food_and_drink) ) - account.entries.create!( + @account.entries.create!( date: months_ago.months.ago.beginning_of_month + 5.days, amount: 15.99, currency: "USD", @@ -310,7 +312,7 @@ class RecurringTransactionTest < ActiveSupport::TestCase transaction = Transaction.create!( category: categories(:one) ) - account.entries.create!( + @account.entries.create!( date: months_ago.months.ago.beginning_of_month + 1.days, amount: 1200.00, currency: "USD", @@ -336,12 +338,11 @@ class RecurringTransactionTest < ActiveSupport::TestCase # Manual recurring transaction tests test "create_from_transaction creates a manual recurring transaction" do - account = @family.accounts.first transaction = Transaction.create!( merchant: @merchant, category: categories(:food_and_drink) ) - entry = account.entries.create!( + entry = @account.entries.create!( date: 2.months.ago, amount: 50.00, currency: "USD", @@ -357,6 +358,7 @@ class RecurringTransactionTest < ActiveSupport::TestCase assert recurring.present? assert recurring.manual? assert_equal @merchant, recurring.merchant + assert_equal @account, recurring.account assert_equal 50.00, recurring.amount assert_equal "USD", recurring.currency assert_equal 2.months.ago.day, recurring.expected_day_of_month @@ -367,8 +369,6 @@ class RecurringTransactionTest < ActiveSupport::TestCase end test "create_from_transaction automatically calculates amount variance from history" do - account = @family.accounts.first - # Create multiple historical transactions with varying amounts on the same day of month amounts = [ 90.00, 100.00, 110.00, 120.00 ] amounts.each_with_index do |amount, i| @@ -376,7 +376,7 @@ class RecurringTransactionTest < ActiveSupport::TestCase merchant: @merchant, category: categories(:food_and_drink) ) - account.entries.create!( + @account.entries.create!( date: (amounts.size - i).months.ago.beginning_of_month + 14.days, # Day 15 amount: amount, currency: "USD", @@ -385,11 +385,12 @@ class RecurringTransactionTest < ActiveSupport::TestCase ) end - # Mark the most recent one as recurring - most_recent_entry = account.entries.order(date: :desc).first + # Mark the most recent one as recurring (find the 120.00 entry we created last) + most_recent_entry = @account.entries.where(amount: 120.00, currency: "USD").order(date: :desc).first recurring = RecurringTransaction.create_from_transaction(most_recent_entry.transaction) assert recurring.manual? + assert_equal @account, recurring.account assert_equal 90.00, recurring.expected_amount_min assert_equal 120.00, recurring.expected_amount_max assert_equal 105.00, recurring.expected_amount_avg # (90 + 100 + 110 + 120) / 4 @@ -399,12 +400,11 @@ class RecurringTransactionTest < ActiveSupport::TestCase end test "create_from_transaction with single transaction sets fixed amount" do - account = @family.accounts.first transaction = Transaction.create!( merchant: @merchant, category: categories(:food_and_drink) ) - entry = account.entries.create!( + entry = @account.entries.create!( date: 1.month.ago, amount: 50.00, currency: "USD", @@ -415,6 +415,7 @@ class RecurringTransactionTest < ActiveSupport::TestCase recurring = RecurringTransaction.create_from_transaction(transaction) assert recurring.manual? + assert_equal @account, recurring.account assert_equal 50.00, recurring.expected_amount_min assert_equal 50.00, recurring.expected_amount_max assert_equal 50.00, recurring.expected_amount_avg @@ -424,10 +425,9 @@ class RecurringTransactionTest < ActiveSupport::TestCase end test "matching_transactions with amount variance matches within range" do - account = @family.accounts.first - # Create manual recurring with variance for day 15 of the month recurring = @family.recurring_transactions.create!( + account: @account, merchant: @merchant, amount: 100.00, currency: "USD", @@ -441,9 +441,9 @@ class RecurringTransactionTest < ActiveSupport::TestCase expected_amount_avg: 100.00 ) - # Create transactions with varying amounts on day 14 (within ±2 days of day 15) + # Create transactions with varying amounts on day 14 (within +/-2 days of day 15) transaction_within_range = Transaction.create!(merchant: @merchant, category: categories(:food_and_drink)) - entry_within = account.entries.create!( + entry_within = @account.entries.create!( date: Date.current.next_month.beginning_of_month + 13.days, # Day 14 amount: 90.00, currency: "USD", @@ -452,7 +452,7 @@ class RecurringTransactionTest < ActiveSupport::TestCase ) transaction_outside_range = Transaction.create!(merchant: @merchant, category: categories(:food_and_drink)) - entry_outside = account.entries.create!( + entry_outside = @account.entries.create!( date: Date.current.next_month.beginning_of_month + 14.days, # Day 15 amount: 150.00, currency: "USD", @@ -468,6 +468,7 @@ class RecurringTransactionTest < ActiveSupport::TestCase test "should_be_inactive? has longer threshold for manual recurring" do # Manual recurring - 6 months threshold manual_recurring = @family.recurring_transactions.create!( + account: @account, merchant: @merchant, amount: 50.00, currency: "USD", @@ -480,6 +481,7 @@ class RecurringTransactionTest < ActiveSupport::TestCase # Auto recurring - 2 months threshold with different amount to avoid unique constraint auto_recurring = @family.recurring_transactions.create!( + account: @account, merchant: @merchant, amount: 60.00, currency: "USD", @@ -496,6 +498,7 @@ class RecurringTransactionTest < ActiveSupport::TestCase test "update_amount_variance updates min/max/avg correctly" do recurring = @family.recurring_transactions.create!( + account: @account, merchant: @merchant, amount: 100.00, currency: "USD", @@ -527,10 +530,9 @@ class RecurringTransactionTest < ActiveSupport::TestCase end test "identify_patterns_for updates variance for manual recurring transactions" do - account = @family.accounts.first - # Create a manual recurring transaction with initial variance manual_recurring = @family.recurring_transactions.create!( + account: @account, merchant: @merchant, amount: 50.00, currency: "USD", @@ -552,7 +554,7 @@ class RecurringTransactionTest < ActiveSupport::TestCase merchant: @merchant, category: categories(:food_and_drink) ) - account.entries.create!( + @account.entries.create!( date: (amounts.size - i).months.ago.beginning_of_month + 14.days, amount: amount, currency: "USD", @@ -578,6 +580,7 @@ class RecurringTransactionTest < ActiveSupport::TestCase test "cleaner does not delete manual recurring transactions" do # Create inactive manual recurring manual_recurring = @family.recurring_transactions.create!( + account: @account, merchant: @merchant, amount: 50.00, currency: "USD", @@ -593,6 +596,7 @@ class RecurringTransactionTest < ActiveSupport::TestCase # Create inactive auto recurring with different merchant auto_recurring = @family.recurring_transactions.create!( + account: @account, merchant: merchants(:amazon), amount: 30.00, currency: "USD", @@ -612,4 +616,93 @@ class RecurringTransactionTest < ActiveSupport::TestCase assert RecurringTransaction.exists?(manual_recurring.id) assert_not RecurringTransaction.exists?(auto_recurring.id) end + + # Account access scoping tests + test "accessible_by scope returns only recurring transactions from accessible accounts" do + admin = users(:family_admin) + member = users(:family_member) + + # depository is shared with family_member (full_control) + # investment is NOT shared with family_member + shared_account = accounts(:depository) + unshared_account = accounts(:investment) + + shared_recurring = @family.recurring_transactions.create!( + account: shared_account, + merchant: @merchant, + amount: 15.99, + currency: "USD", + expected_day_of_month: 5, + last_occurrence_date: 1.month.ago.to_date, + next_expected_date: 5.days.from_now.to_date, + status: "active" + ) + + unshared_recurring = @family.recurring_transactions.create!( + account: unshared_account, + merchant: merchants(:amazon), + amount: 9.99, + currency: "USD", + expected_day_of_month: 15, + last_occurrence_date: 1.month.ago.to_date, + next_expected_date: 5.days.from_now.to_date, + status: "active" + ) + + # Admin (owner of all accounts) sees both + admin_results = @family.recurring_transactions.accessible_by(admin) + assert_includes admin_results, shared_recurring + assert_includes admin_results, unshared_recurring + + # Family member only sees the one from the shared account + member_results = @family.recurring_transactions.accessible_by(member) + assert_includes member_results, shared_recurring + assert_not_includes member_results, unshared_recurring + end + + test "identifier creates per-account patterns for same merchant on different accounts" do + account_a = accounts(:depository) + account_b = accounts(:credit_card) + + # Create pattern on account A + [ 0, 1, 2 ].each do |months_ago| + transaction = Transaction.create!( + merchant: @merchant, + category: categories(:food_and_drink) + ) + account_a.entries.create!( + date: months_ago.months.ago.beginning_of_month + 5.days, + amount: 15.99, + currency: "USD", + name: "Netflix Subscription", + entryable: transaction + ) + end + + # Create same pattern on account B + [ 0, 1, 2 ].each do |months_ago| + transaction = Transaction.create!( + merchant: @merchant, + category: categories(:food_and_drink) + ) + account_b.entries.create!( + date: months_ago.months.ago.beginning_of_month + 5.days, + amount: 15.99, + currency: "USD", + name: "Netflix Subscription", + entryable: transaction + ) + end + + assert_difference "@family.recurring_transactions.count", 2 do + RecurringTransaction.identify_patterns_for!(@family) + end + + recurring_a = @family.recurring_transactions.find_by(account: account_a, merchant: @merchant, amount: 15.99) + recurring_b = @family.recurring_transactions.find_by(account: account_b, merchant: @merchant, amount: 15.99) + + assert recurring_a.present? + assert recurring_b.present? + assert_not_equal recurring_a, recurring_b + end end diff --git a/test/models/rule_import_test.rb b/test/models/rule_import_test.rb index 017194887..ca95f8390 100644 --- a/test/models/rule_import_test.rb +++ b/test/models/rule_import_test.rb @@ -6,7 +6,6 @@ class RuleImportTest < ActiveSupport::TestCase @category = @family.categories.create!( name: "Groceries", color: "#407706", - classification: "expense", lucide_icon: "shopping-basket" ) @csv = <<~CSV @@ -110,7 +109,6 @@ class RuleImportTest < ActiveSupport::TestCase new_category = Category.find_by!(family: @family, name: "Coffee Shops") assert_equal Category::UNCATEGORIZED_COLOR, new_category.color - assert_equal "expense", new_category.classification rule = Rule.find_by!(family: @family, name: "New category rule") action = rule.actions.first @@ -235,4 +233,83 @@ class RuleImportTest < ActiveSupport::TestCase import.send(:import!) end end + + test "imports valid JSON conditions whose values contain escaped quotes" do + csv = CSV.generate do |out| + out << %w[name resource_type active effective_date conditions actions] + out << [ + "Quoted value rule", + "transaction", + true, + "", + [ { condition_type: "transaction_name", operator: "=", value: "ני\\u0022ע-קניה" } ].to_json, + [ { action_type: "set_transaction_name", value: "Quoted transfer" } ].to_json + ] + end + + import = @family.imports.create!(type: "RuleImport", raw_file_str: csv, col_sep: ",") + import.generate_rows_from_csv + + assert_nothing_raised do + import.send(:import!) + end + + rule = Rule.find_by!(family: @family, name: "Quoted value rule") + condition = rule.conditions.first + assert_equal "ני\"ע-קניה", condition.value + end + + test "imports wrapped JSON payloads from legacy rows" do + wrapped_conditions = [ { condition_type: "transaction_name", operator: "like", value: "legacy grocery" } ].to_json.to_json + wrapped_actions = [ { action_type: "set_transaction_category", value: "Groceries" } ].to_json.to_json + + csv = CSV.generate do |out| + out << %w[name resource_type active effective_date conditions actions] + out << [ + "Wrapped payload rule", + "transaction", + true, + "", + wrapped_conditions, + wrapped_actions + ] + end + + import = @family.imports.create!(type: "RuleImport", raw_file_str: csv, col_sep: ",") + import.generate_rows_from_csv + + assert_nothing_raised do + import.send(:import!) + end + + rule = Rule.find_by!(family: @family, name: "Wrapped payload rule") + condition = rule.conditions.first + action = rule.actions.first + + assert_equal "legacy grocery", condition.value + assert_equal @category.id, action.value + end + + test "preserves literal backslash sequences in valid JSON values" do + csv = CSV.generate do |out| + out << %w[name resource_type active effective_date conditions actions] + out << [ + "Literal backslash rule", + "transaction", + true, + "", + [ { condition_type: "transaction_name", operator: "=", value: 'C:\new\test' } ].to_json, + [ { action_type: "set_transaction_name", value: "Path rule" } ].to_json + ] + end + + import = @family.imports.create!(type: "RuleImport", raw_file_str: csv, col_sep: ",") + import.generate_rows_from_csv + import.send(:import!) + + rule = Rule.find_by!(family: @family, name: "Literal backslash rule") + condition = rule.conditions.first + + assert_equal 'C:\new\test', condition.value + end end diff --git a/test/models/rule_test.rb b/test/models/rule_test.rb index 6f50221be..cc4256436 100644 --- a/test/models/rule_test.rb +++ b/test/models/rule_test.rb @@ -294,4 +294,58 @@ class RuleTest < ActiveSupport::TestCase test "total_affected_resource_count returns zero for empty rules" do assert_equal 0, Rule.total_affected_resource_count([]) end + + test "rule matching on transaction account" do + # Create a second account + other_account = @family.accounts.create!( + name: "Other account", + balance: 500, + currency: "USD", + accountable: Depository.new + ) + + # Transaction on the target account + transaction_entry1 = create_transaction( + date: Date.current, + account: @account, + amount: 50 + ) + + # Transaction on another account + transaction_entry2 = create_transaction( + date: Date.current, + account: other_account, + amount: 75 + ) + + rule = Rule.create!( + family: @family, + resource_type: "transaction", + effective_date: 1.day.ago.to_date, + conditions: [ + Rule::Condition.new( + condition_type: "transaction_account", + operator: "=", + value: @account.id + ) + ], + actions: [ + Rule::Action.new( + action_type: "set_transaction_category", + value: @groceries_category.id + ) + ] + ) + + rule.apply + + transaction_entry1.reload + transaction_entry2.reload + + assert_equal @groceries_category, transaction_entry1.transaction.category, + "Transaction on selected account should be categorized" + + assert_nil transaction_entry2.transaction.category, + "Transaction on other account should not be categorized" + end end diff --git a/test/models/security_test.rb b/test/models/security_test.rb index 0e6ef3c8f..ee18a14fd 100644 --- a/test/models/security_test.rb +++ b/test/models/security_test.rb @@ -38,4 +38,34 @@ class SecurityTest < ActiveSupport::TestCase assert_not duplicate.valid? assert_equal [ "has already been taken" ], duplicate.errors[:ticker] end + + test "cash_for lazily creates a per-account synthetic cash security" do + account = accounts(:investment) + + cash = Security.cash_for(account) + + assert cash.persisted? + assert cash.cash? + assert cash.offline? + assert_equal "Cash", cash.name + assert_includes cash.ticker, account.id.upcase + end + + test "cash_for returns the same security on repeated calls" do + account = accounts(:investment) + + first = Security.cash_for(account) + second = Security.cash_for(account) + + assert_equal first.id, second.id + end + + test "standard scope excludes cash securities" do + account = accounts(:investment) + Security.cash_for(account) + + standard_tickers = Security.standard.pluck(:ticker) + + assert_not_includes standard_tickers, "CASH-#{account.id.upcase}" + end end diff --git a/test/models/simplefin_account_processor_test.rb b/test/models/simplefin_account_processor_test.rb index 11b9b4456..0c6a33b88 100644 --- a/test/models/simplefin_account_processor_test.rb +++ b/test/models/simplefin_account_processor_test.rb @@ -158,24 +158,43 @@ class SimplefinAccountProcessorTest < ActiveSupport::TestCase assert_equal BigDecimal("-25"), acct.reload.balance end - test "mislinked as asset but mapper infers credit → normalize as liability" do + test "linked depository account type takes precedence over mapper-inferred liability" do sfin_acct = SimplefinAccount.create!( simplefin_item: @item, name: "Visa Signature", - account_id: "cc_mislinked", + account_id: "cc_mislinked_asset", currency: "USD", account_type: "credit", current_balance: BigDecimal("100.00"), available_balance: BigDecimal("5000.00") ) - # Link to an asset account intentionally acct = accounts(:depository) acct.update!(simplefin_account: sfin_acct) SimplefinAccount::Processor.new(sfin_acct).send(:process_account!) - # Mapper should infer liability from name; final should be negative + # Manual selection as depository; final should be the same + assert_equal BigDecimal("100.00"), acct.reload.balance + end + + test "linked credit card account type takes precedence over mapper-inferred liability" do + sfin_acct = SimplefinAccount.create!( + simplefin_item: @item, + name: "Visa Signature", + account_id: "cc_mislinked_liability", + currency: "USD", + account_type: "credit", + current_balance: BigDecimal("100.00"), + available_balance: BigDecimal("5000.00") + ) + + acct = accounts(:credit_card) + acct.update!(simplefin_account: sfin_acct) + + SimplefinAccount::Processor.new(sfin_acct).send(:process_account!) + + # Liability has flipped sign; final should be negative assert_equal BigDecimal("-100.00"), acct.reload.balance end diff --git a/test/models/simplefin_entry/processor_test.rb b/test/models/simplefin_entry/processor_test.rb index 72a57ee0a..87061d04d 100644 --- a/test/models/simplefin_entry/processor_test.rb +++ b/test/models/simplefin_entry/processor_test.rb @@ -137,7 +137,7 @@ class SimplefinEntry::ProcessorTest < ActiveSupport::TestCase entry = @account.entries.find_by!(external_id: "simplefin_tx_pending_zero_posted_1", source: "simplefin") # For depository accounts, processor prefers posted, then transacted; posted==0 should be treated as missing - assert_equal Time.at(t_epoch).to_date, entry.date, "expected entry.date to use transacted_at when posted==0" + assert_equal Time.at(t_epoch).utc.to_date, entry.date, "expected entry.date to use transacted_at when posted==0" sf = entry.transaction.extra.fetch("simplefin") assert_equal true, sf["pending"], "expected pending flag to be true when posted==0 and/or pending=true" end diff --git a/test/models/snaptrade_account_test.rb b/test/models/snaptrade_account_test.rb index e500c1085..48efc0f30 100644 --- a/test/models/snaptrade_account_test.rb +++ b/test/models/snaptrade_account_test.rb @@ -1,139 +1,74 @@ require "test_helper" class SnaptradeAccountTest < ActiveSupport::TestCase - fixtures :families, :snaptrade_items, :snaptrade_accounts setup do - @family = families(:dylan_family) - @snaptrade_item = snaptrade_items(:configured_item) - @snaptrade_account = snaptrade_accounts(:fidelity_401k) - end + @family_a = families(:dylan_family) + @family_b = families(:empty) - test "validates presence of name" do - @snaptrade_account.name = nil - assert_not @snaptrade_account.valid? - assert_includes @snaptrade_account.errors[:name], "can't be blank" - end - - test "validates presence of currency" do - @snaptrade_account.currency = nil - assert_not @snaptrade_account.valid? - assert_includes @snaptrade_account.errors[:currency], "can't be blank" - end - - test "ensure_account_provider! creates link when account provided" do - account = @family.accounts.create!( - name: "Test Investment", - balance: 10000, - currency: "USD", - accountable: Investment.new + @item_a = SnaptradeItem.create!( + family: @family_a, + name: "Family A Broker", + client_id: "client_a", + consumer_key: "key_a", + status: "good" ) - assert_nil @snaptrade_account.account_provider - - @snaptrade_account.ensure_account_provider!(account) - @snaptrade_account.reload - - assert_not_nil @snaptrade_account.account_provider - assert_equal account, @snaptrade_account.current_account + @item_b = SnaptradeItem.create!( + family: @family_b, + name: "Family B Broker", + client_id: "client_b", + consumer_key: "key_b", + status: "good" + ) end - test "ensure_account_provider! updates link when account changes" do - account1 = @family.accounts.create!( - name: "First Account", - balance: 10000, + test "same snaptrade_account_id can be linked under different snaptrade_items" do + SnaptradeAccount.create!( + snaptrade_item: @item_a, + snaptrade_account_id: "shared_snap_uuid_1", + name: "IRA", currency: "USD", - accountable: Investment.new - ) - account2 = @family.accounts.create!( - name: "Second Account", - balance: 20000, - currency: "USD", - accountable: Investment.new + current_balance: 5000 ) - @snaptrade_account.ensure_account_provider!(account1) - assert_equal account1, @snaptrade_account.reload.current_account - - @snaptrade_account.ensure_account_provider!(account2) - assert_equal account2, @snaptrade_account.reload.current_account + assert_difference "SnaptradeAccount.count", 1 do + SnaptradeAccount.create!( + snaptrade_item: @item_b, + snaptrade_account_id: "shared_snap_uuid_1", + name: "IRA", + currency: "USD", + current_balance: 5000 + ) + end end - test "ensure_account_provider! is idempotent" do - account = @family.accounts.create!( - name: "Test Investment", - balance: 10000, + test "same snaptrade_account_id cannot appear twice under the same snaptrade_item" do + SnaptradeAccount.create!( + snaptrade_item: @item_a, + snaptrade_account_id: "dup_snap_uuid", + name: "Brokerage", currency: "USD", - accountable: Investment.new + current_balance: 1000 ) - @snaptrade_account.ensure_account_provider!(account) - provider1 = @snaptrade_account.reload.account_provider + duplicate = SnaptradeAccount.new( + snaptrade_item: @item_a, + snaptrade_account_id: "dup_snap_uuid", + name: "Brokerage", + currency: "USD", + current_balance: 1000 + ) + refute duplicate.valid? + assert_includes duplicate.errors[:snaptrade_account_id], "has already been taken" - @snaptrade_account.ensure_account_provider!(account) - provider2 = @snaptrade_account.reload.account_provider - - assert_equal provider1.id, provider2.id - end - - test "upsert_holdings_snapshot! stores holdings and updates timestamp" do - holdings = [ - { "symbol" => { "symbol" => "AAPL" }, "units" => 10 }, - { "symbol" => { "symbol" => "MSFT" }, "units" => 5 } - ] - - @snaptrade_account.upsert_holdings_snapshot!(holdings) - - assert_equal holdings, @snaptrade_account.raw_holdings_payload - assert_not_nil @snaptrade_account.last_holdings_sync - end - - test "upsert_activities_snapshot! stores activities and updates timestamp" do - activities = [ - { "id" => "act1", "type" => "BUY", "amount" => 1000 }, - { "id" => "act2", "type" => "DIVIDEND", "amount" => 50 } - ] - - @snaptrade_account.upsert_activities_snapshot!(activities) - - assert_equal activities, @snaptrade_account.raw_activities_payload - assert_not_nil @snaptrade_account.last_activities_sync - end - - test "upsert_from_snaptrade! extracts data from API response" do - # Use a Hash that mimics the SnapTrade SDK response structure - api_response = { - "id" => "new_account_id", - "brokerage_authorization" => "auth_xyz", - "number" => "9999999", - "name" => "Schwab Brokerage", - "status" => "active", - "balance" => { - "total" => { "amount" => 125000, "currency" => "USD" } - }, - "meta" => { "type" => "INDIVIDUAL", "institution_name" => "Charles Schwab" } - } - - @snaptrade_account.upsert_from_snaptrade!(api_response) - - assert_equal "new_account_id", @snaptrade_account.snaptrade_account_id - assert_equal "auth_xyz", @snaptrade_account.snaptrade_authorization_id - assert_equal "9999999", @snaptrade_account.account_number - assert_equal "Schwab Brokerage", @snaptrade_account.name - assert_equal "Charles Schwab", @snaptrade_account.brokerage_name - assert_equal 125000, @snaptrade_account.current_balance.to_i - assert_equal "INDIVIDUAL", @snaptrade_account.account_type - end - - test "snaptrade_credentials returns credentials from parent item" do - credentials = @snaptrade_account.snaptrade_credentials - - assert_equal "user_123", credentials[:user_id] - assert_equal "secret_abc", credentials[:user_secret] - end - - test "snaptrade_provider returns provider from parent item" do - provider = @snaptrade_account.snaptrade_provider - - assert_instance_of Provider::Snaptrade, provider + assert_raises(ActiveRecord::RecordInvalid) do + SnaptradeAccount.create!( + snaptrade_item: @item_a, + snaptrade_account_id: "dup_snap_uuid", + name: "Brokerage", + currency: "USD", + current_balance: 1000 + ) + end end end diff --git a/test/models/sure_import_test.rb b/test/models/sure_import_test.rb new file mode 100644 index 000000000..87d70db8c --- /dev/null +++ b/test/models/sure_import_test.rb @@ -0,0 +1,187 @@ +require "test_helper" + +class SureImportTest < ActiveSupport::TestCase + include ActiveJob::TestHelper + + setup do + @family = families(:dylan_family) + @import = @family.imports.create!(type: "SureImport") + end + + test "dry_run reflects attached ndjson content" do + ndjson = [ + { type: "Account", data: { id: "uuid-1", name: "Test", balance: "1000", currency: "USD", accountable_type: "Depository" } }, + { type: "Transaction", data: { id: "uuid-2" } } + ].map(&:to_json).join("\n") + + attach_ndjson(ndjson) + + dry_run = @import.dry_run + + assert_equal 1, dry_run[:accounts] + assert_equal 1, dry_run[:transactions] + end + + test "publishable? is false when attached file has no supported records" do + ndjson = { type: "UnknownType", data: {} }.to_json + attach_ndjson(ndjson) + + assert @import.uploaded? + assert_not @import.publishable? + end + + test "column_keys required_column_keys and mapping_steps are empty" do + assert_equal [], @import.column_keys + assert_equal [], @import.required_column_keys + assert_equal [], @import.mapping_steps + end + + test "max_row_count is higher than standard imports" do + assert_equal 100_000, @import.max_row_count + end + + test "csv_template returns nil" do + assert_nil @import.csv_template + end + + test "uploaded? returns false without ndjson attachment" do + assert_not @import.uploaded? + end + + test "uploaded? returns true with valid ndjson attachment" do + attach_ndjson(build_ndjson([ + { type: "Account", data: { id: "uuid-1", name: "Test", balance: "1000", currency: "USD", accountable_type: "Depository" } } + ])) + + assert @import.uploaded? + end + + test "uploaded? returns false with invalid ndjson attachment" do + attach_ndjson("not valid json") + + assert_not @import.uploaded? + end + + test "configured? and cleaned? follow uploaded?" do + attach_ndjson(build_ndjson([ + { type: "Account", data: { id: "uuid-1", name: "Test", balance: "1000", currency: "USD", accountable_type: "Depository" } } + ])) + + assert @import.configured? + assert @import.cleaned? + end + + test "publishable? returns true when uploaded and valid" do + attach_ndjson(build_ndjson([ + { type: "Account", data: { id: "uuid-1", name: "Test", balance: "1000", currency: "USD", accountable_type: "Depository" } } + ])) + + assert @import.publishable? + end + + test "dry_run returns counts by type" do + attach_ndjson(build_ndjson([ + { type: "Account", data: { id: "uuid-1" } }, + { type: "Account", data: { id: "uuid-2" } }, + { type: "Category", data: { id: "uuid-3" } }, + { type: "Transaction", data: { id: "uuid-4" } }, + { type: "Transaction", data: { id: "uuid-5" } }, + { type: "Transaction", data: { id: "uuid-6" } } + ])) + + dry_run = @import.dry_run + + assert_equal 2, dry_run[:accounts] + assert_equal 1, dry_run[:categories] + assert_equal 3, dry_run[:transactions] + assert_equal 0, dry_run[:tags] + end + + test "sync_ndjson_rows_count! sets total row count" do + attach_ndjson(build_ndjson([ + { type: "Account", data: { id: "uuid-1" } }, + { type: "Category", data: { id: "uuid-2" } }, + { type: "Transaction", data: { id: "uuid-3" } } + ])) + + @import.sync_ndjson_rows_count! + + assert_equal 3, @import.rows_count + end + + test "publishes import successfully" do + attach_ndjson(build_ndjson([ + { type: "Account", data: { + id: "uuid-1", + name: "Import Test Account", + balance: "1000.00", + currency: "USD", + accountable_type: "Depository", + accountable: { subtype: "checking" } + } } + ])) + + initial_account_count = @family.accounts.count + + @import.publish + + assert_equal "complete", @import.status + assert_equal initial_account_count + 1, @family.accounts.count + + account = @family.accounts.find_by(name: "Import Test Account") + assert_not_nil account + assert_equal 1000.0, account.balance.to_f + assert_equal "USD", account.currency + assert_equal "Depository", account.accountable_type + end + + test "import tracks created accounts for revert" do + attach_ndjson(build_ndjson([ + { type: "Account", data: { + id: "uuid-1", + name: "Revertable Account", + balance: "500.00", + currency: "USD", + accountable_type: "Depository" + } } + ])) + + @import.publish + + assert_equal 1, @import.accounts.count + assert_equal "Revertable Account", @import.accounts.first.name + end + + test "publishes later enqueues job" do + attach_ndjson(build_ndjson([ + { type: "Account", data: { + id: "uuid-1", + name: "Async Account", + balance: "100", + currency: "USD", + accountable_type: "Depository" + } } + ])) + + assert_enqueued_with job: ImportJob, args: [ @import ] do + @import.publish_later + end + + assert_equal "importing", @import.status + end + + private + + def attach_ndjson(ndjson) + @import.ndjson_file.attach( + io: StringIO.new(ndjson), + filename: "all.ndjson", + content_type: "application/x-ndjson" + ) + @import.sync_ndjson_rows_count! + end + + def build_ndjson(records) + records.map(&:to_json).join("\n") + end +end diff --git a/test/models/trade_test.rb b/test/models/trade_test.rb index ab8ba56ba..bcf09b192 100644 --- a/test/models/trade_test.rb +++ b/test/models/trade_test.rb @@ -39,6 +39,19 @@ class TradeTest < ActiveSupport::TestCase assert_equal precise_price, trade.price end + test "fee defaults to 0" do + security = Security.create!(ticker: "FEETEST", exchange_operating_mic: "XNAS") + trade = Trade.create!( + security: security, + price: 100, + qty: 10, + currency: "USD", + investment_activity_label: "Buy" + ) + + assert_equal 0, trade.fee + end + test "price is rounded to 10 decimal places" do security = Security.create!(ticker: "TEST", exchange_operating_mic: "XNAS") diff --git a/test/models/transaction/grouper/by_merchant_or_name_test.rb b/test/models/transaction/grouper/by_merchant_or_name_test.rb new file mode 100644 index 000000000..adb6320fa --- /dev/null +++ b/test/models/transaction/grouper/by_merchant_or_name_test.rb @@ -0,0 +1,140 @@ +require "test_helper" + +class Transaction::Grouper::ByMerchantOrNameTest < ActiveSupport::TestCase + include EntriesTestHelper + + setup do + @family = families(:dylan_family) + @account = accounts(:depository) + # Clear existing entries for isolation + @family.accounts.each { |a| a.entries.delete_all } + end + + test "groups uncategorized transactions by merchant name when merchant present" do + merchant = merchants(:netflix) + create_transaction(account: @account, name: "NETFLIX.COM", merchant: merchant) + create_transaction(account: @account, name: "Netflix Monthly", merchant: merchant) + + groups = Transaction::Grouper::ByMerchantOrName.call(@family.entries) + + assert_equal 1, groups.size + assert_equal "Netflix", groups.first.grouping_key + assert_equal 2, groups.first.entries.size + end + + test "falls back to entry name when no merchant" do + create_transaction(account: @account, name: "AMZN MKTP US") + create_transaction(account: @account, name: "AMZN MKTP US") + + groups = Transaction::Grouper::ByMerchantOrName.call(@family.entries) + + assert_equal 1, groups.size + assert_equal "AMZN MKTP US", groups.first.grouping_key + assert_equal 2, groups.first.entries.size + end + + test "creates separate groups for different names" do + create_transaction(account: @account, name: "Starbucks") + create_transaction(account: @account, name: "Netflix") + + groups = Transaction::Grouper::ByMerchantOrName.call(@family.entries) + + assert_equal 2, groups.size + end + + test "creates separate groups for same name with different types" do + create_transaction(account: @account, name: "Refund", amount: 50) # expense + create_transaction(account: @account, name: "Refund", amount: -50) # income + + groups = Transaction::Grouper::ByMerchantOrName.call(@family.entries) + + assert_equal 2, groups.size + types = groups.map(&:transaction_type).sort + assert_equal %w[expense income], types + end + + test "sets transaction_type to income for negative amounts" do + create_transaction(account: @account, name: "Paycheck", amount: -1000) + + groups = Transaction::Grouper::ByMerchantOrName.call(@family.entries) + + assert_equal "income", groups.first.transaction_type + end + + test "sets transaction_type to expense for positive amounts" do + create_transaction(account: @account, name: "Coffee", amount: 5) + + groups = Transaction::Grouper::ByMerchantOrName.call(@family.entries) + + assert_equal "expense", groups.first.transaction_type + end + + test "excludes transfer kinds" do + create_transaction(account: @account, name: "CC Payment", kind: "cc_payment") + create_transaction(account: @account, name: "Funds Move", kind: "funds_movement") + create_transaction(account: @account, name: "Regular") + + groups = Transaction::Grouper::ByMerchantOrName.call(@family.entries) + + assert_equal 1, groups.size + assert_equal "Regular", groups.first.grouping_key + end + + test "excludes already-categorized transactions" do + create_transaction(account: @account, name: "Categorized", category: categories(:food_and_drink)) + create_transaction(account: @account, name: "Uncategorized") + + groups = Transaction::Grouper::ByMerchantOrName.call(@family.entries) + + assert_equal 1, groups.size + assert_equal "Uncategorized", groups.first.grouping_key + end + + test "excludes excluded entries" do + entry = create_transaction(account: @account, name: "Excluded") + entry.update!(excluded: true) + create_transaction(account: @account, name: "Visible") + + groups = Transaction::Grouper::ByMerchantOrName.call(@family.entries) + + assert_equal 1, groups.size + assert_equal "Visible", groups.first.grouping_key + end + + test "returns empty array when all transactions are categorized" do + create_transaction(account: @account, name: "Coffee", category: categories(:food_and_drink)) + + groups = Transaction::Grouper::ByMerchantOrName.call(@family.entries) + + assert_empty groups + end + + test "sorts groups by count descending then name ascending" do + 3.times { create_transaction(account: @account, name: "Starbucks") } + create_transaction(account: @account, name: "Netflix") + + groups = Transaction::Grouper::ByMerchantOrName.call(@family.entries) + + assert_equal "Starbucks", groups.first.grouping_key + assert_equal "Netflix", groups.last.grouping_key + end + + test "respects limit and offset" do + create_transaction(account: @account, name: "A") + create_transaction(account: @account, name: "B") + create_transaction(account: @account, name: "C") + + # limit + groups = Transaction::Grouper::ByMerchantOrName.call(@family.entries, limit: 2) + assert_equal 2, groups.size + + # all groups + all_groups = Transaction::Grouper::ByMerchantOrName.call(@family.entries, limit: 10) + assert_equal 3, all_groups.size + + # offset skips leading groups + offset_groups = Transaction::Grouper::ByMerchantOrName.call(@family.entries, limit: 10, offset: 1) + assert_equal 2, offset_groups.size + assert_not_includes offset_groups.map(&:grouping_key), all_groups.first.grouping_key + end +end diff --git a/test/models/transaction/search_test.rb b/test/models/transaction/search_test.rb index c6d817ce1..1d7521c0e 100644 --- a/test/models/transaction/search_test.rb +++ b/test/models/transaction/search_test.rb @@ -152,8 +152,7 @@ class Transaction::SearchTest < ActiveSupport::TestCase # Create a travel category for testing travel_category = @family.categories.create!( name: "Travel", - color: "#3b82f6", - classification: "expense" + color: "#3b82f6" ) # Create transactions with different categories diff --git a/test/models/transaction_attachment_validation_test.rb b/test/models/transaction_attachment_validation_test.rb new file mode 100644 index 000000000..16182ccdb --- /dev/null +++ b/test/models/transaction_attachment_validation_test.rb @@ -0,0 +1,61 @@ +require "test_helper" + +class TransactionAttachmentValidationTest < ActiveSupport::TestCase + setup do + @transaction = transactions(:one) + end + + test "should validate attachment content types" do + # Valid content type should pass + @transaction.attachments.attach( + io: StringIO.new("valid content"), + filename: "test.pdf", + content_type: "application/pdf" + ) + assert @transaction.valid? + + # Invalid content type should fail + @transaction.attachments.attach( + io: StringIO.new("invalid content"), + filename: "test.txt", + content_type: "text/plain" + ) + assert_not @transaction.valid? + assert_includes @transaction.errors.full_messages_for(:attachments).join, "unsupported format" + end + + test "should validate attachment count limit" do + # Fill up to the limit + Transaction::MAX_ATTACHMENTS_PER_TRANSACTION.times do |i| + @transaction.attachments.attach( + io: StringIO.new("content #{i}"), + filename: "file#{i}.pdf", + content_type: "application/pdf" + ) + end + assert @transaction.valid? + + # Exceeding the limit should fail + @transaction.attachments.attach( + io: StringIO.new("extra content"), + filename: "extra.pdf", + content_type: "application/pdf" + ) + assert_not @transaction.valid? + assert_includes @transaction.errors.full_messages_for(:attachments).join, "cannot exceed" + end + + test "should validate attachment file size" do + # Create a mock large attachment + large_content = "x" * (Transaction::MAX_ATTACHMENT_SIZE + 1) + + @transaction.attachments.attach( + io: StringIO.new(large_content), + filename: "large.pdf", + content_type: "application/pdf" + ) + + assert_not @transaction.valid? + assert_includes @transaction.errors.full_messages_for(:attachments).join, "too large" + end +end diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 85501c1d4..46aa0637f 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -149,7 +149,7 @@ class UserTest < ActiveSupport::TestCase test "ai_available? returns true when openai access token set in settings" do Rails.application.config.app_mode.stubs(:self_hosted?).returns(true) previous = Setting.openai_access_token - with_env_overrides OPENAI_ACCESS_TOKEN: nil do + with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: nil, EXTERNAL_ASSISTANT_TOKEN: nil do Setting.openai_access_token = nil assert_not @user.ai_available? @@ -160,6 +160,43 @@ class UserTest < ActiveSupport::TestCase Setting.openai_access_token = previous end + test "ai_available? returns true when external assistant is configured and family type is external" do + Rails.application.config.app_mode.stubs(:self_hosted?).returns(true) + previous = Setting.openai_access_token + @user.family.update!(assistant_type: "external") + with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: "http://localhost:18789/v1/chat", EXTERNAL_ASSISTANT_TOKEN: "test-token" do + Setting.openai_access_token = nil + assert @user.ai_available? + end + ensure + Setting.openai_access_token = previous + @user.family.update!(assistant_type: "builtin") + end + + test "ai_available? returns false when external assistant is configured but family type is builtin" do + Rails.application.config.app_mode.stubs(:self_hosted?).returns(true) + previous = Setting.openai_access_token + with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: "http://localhost:18789/v1/chat", EXTERNAL_ASSISTANT_TOKEN: "test-token" do + Setting.openai_access_token = nil + assert_not @user.ai_available? + end + ensure + Setting.openai_access_token = previous + end + + test "ai_available? returns false when external assistant is configured but user is not in allowlist" do + Rails.application.config.app_mode.stubs(:self_hosted?).returns(true) + previous = Setting.openai_access_token + @user.family.update!(assistant_type: "external") + with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: "http://localhost:18789/v1/chat", EXTERNAL_ASSISTANT_TOKEN: "test-token", EXTERNAL_ASSISTANT_ALLOWED_EMAILS: "other@example.com" do + Setting.openai_access_token = nil + assert_not @user.ai_available? + end + ensure + Setting.openai_access_token = previous + @user.family.update!(assistant_type: "builtin") + end + test "intro layout collapses sidebars and enables ai" do user = User.new( family: families(:empty), @@ -325,6 +362,33 @@ class UserTest < ActiveSupport::TestCase "Should return false when section key is missing from collapsed_sections" end + # Default account for transactions + test "default_account_for_transactions returns account when active and manual" do + account = accounts(:depository) + @user.update!(default_account: account) + assert_equal account, @user.default_account_for_transactions + end + + test "default_account_for_transactions returns nil when account is disabled" do + account = accounts(:depository) + @user.update!(default_account: account) + account.disable! + assert_nil @user.default_account_for_transactions + end + + test "default_account_for_transactions returns nil when account is linked" do + account = accounts(:depository) + @user.update!(default_account: account) + plaid_account = plaid_accounts(:one) + AccountProvider.create!(account: account, provider: plaid_account) + account.reload + assert_nil @user.default_account_for_transactions + end + + test "default_account_for_transactions returns nil when no default set" do + assert_nil @user.default_account_for_transactions + end + # SSO-only user security tests test "sso_only? returns true for user with OIDC identity and no password" do sso_user = users(:sso_only) diff --git a/test/models/vector_store/embeddable_test.rb b/test/models/vector_store/embeddable_test.rb new file mode 100644 index 000000000..4e01eb3a8 --- /dev/null +++ b/test/models/vector_store/embeddable_test.rb @@ -0,0 +1,204 @@ +require "test_helper" + +class VectorStore::EmbeddableTest < ActiveSupport::TestCase + class EmbeddableHost + include VectorStore::Embeddable + # Expose private methods for testing + public :extract_text, :chunk_text, :embed, :embed_batch + end + + setup do + @host = EmbeddableHost.new + end + + # --- extract_text --- + + test "extract_text returns plain text for .txt files" do + result = @host.extract_text("Hello world", "notes.txt") + assert_equal "Hello world", result + end + + test "extract_text returns content for markdown files" do + result = @host.extract_text("# Heading\n\nBody", "readme.md") + assert_equal "# Heading\n\nBody", result + end + + test "extract_text returns content for code files" do + result = @host.extract_text("def foo; end", "app.rb") + assert_equal "def foo; end", result + end + + test "extract_text returns nil for unsupported binary formats" do + assert_nil @host.extract_text("\x00\x01binary", "photo.png") + assert_nil @host.extract_text("\x00\x01binary", "archive.zip") + end + + test "extract_text handles PDF files" do + pdf_content = "fake pdf bytes" + mock_page = mock("page") + mock_page.stubs(:text).returns("Page 1 content") + + mock_reader = mock("reader") + mock_reader.stubs(:pages).returns([ mock_page ]) + + PDF::Reader.expects(:new).with(instance_of(StringIO)).returns(mock_reader) + + result = @host.extract_text(pdf_content, "document.pdf") + assert_equal "Page 1 content", result + end + + test "extract_text returns nil when PDF extraction fails" do + PDF::Reader.expects(:new).raises(StandardError, "corrupt pdf") + + result = @host.extract_text("bad data", "broken.pdf") + assert_nil result + end + + # --- chunk_text --- + + test "chunk_text returns empty array for blank text" do + assert_equal [], @host.chunk_text("") + assert_equal [], @host.chunk_text(nil) + end + + test "chunk_text returns single chunk for short text" do + text = "Short paragraph." + chunks = @host.chunk_text(text) + assert_equal 1, chunks.size + assert_equal "Short paragraph.", chunks.first + end + + test "chunk_text splits on paragraph boundaries" do + # Create text that exceeds CHUNK_SIZE when combined + para1 = "A" * 1200 + para2 = "B" * 1200 + text = "#{para1}\n\n#{para2}" + + chunks = @host.chunk_text(text) + assert_equal 2, chunks.size + assert_includes chunks.first, "A" * 1200 + assert_includes chunks.last, "B" * 1200 + end + + test "chunk_text includes overlap between chunks" do + para1 = "A" * 1500 + para2 = "B" * 1500 + text = "#{para1}\n\n#{para2}" + + chunks = @host.chunk_text(text) + assert_equal 2, chunks.size + # Second chunk should start with overlap from end of first chunk + overlap = para1.last(VectorStore::Embeddable::CHUNK_OVERLAP) + assert chunks.last.start_with?(overlap) + end + + test "chunk_text keeps small paragraphs together" do + paragraphs = Array.new(5) { |i| "Paragraph #{i} content." } + text = paragraphs.join("\n\n") + + chunks = @host.chunk_text(text) + assert_equal 1, chunks.size + end + + test "chunk_text hard-splits oversized paragraphs" do + # A single paragraph longer than CHUNK_SIZE with no paragraph breaks + long_para = "X" * 5000 + chunks = @host.chunk_text(long_para) + + assert chunks.size > 1 + chunks.each do |chunk| + assert chunk.length <= VectorStore::Embeddable::CHUNK_SIZE + VectorStore::Embeddable::CHUNK_OVERLAP + 2, + "Chunk too large: #{chunk.length} chars" + end + end + + # --- embed --- + + test "embed calls embedding endpoint and returns vector" do + expected_vector = [ 0.1, 0.2, 0.3 ] + stub_response = { "data" => [ { "embedding" => expected_vector, "index" => 0 } ] } + + mock_client = mock("faraday") + mock_client.expects(:post).with("embeddings").yields(mock_request).returns( + OpenStruct.new(body: stub_response) + ) + @host.instance_variable_set(:@embedding_client, mock_client) + + result = @host.embed("test text") + assert_equal expected_vector, result + end + + test "embed raises on failed response" do + mock_client = mock("faraday") + mock_client.expects(:post).with("embeddings").yields(mock_request).returns( + OpenStruct.new(body: { "error" => "bad request" }) + ) + @host.instance_variable_set(:@embedding_client, mock_client) + + assert_raises(VectorStore::Error) { @host.embed("test text") } + end + + # --- embed_batch --- + + test "embed_batch processes texts and returns ordered vectors" do + texts = [ "first", "second", "third" ] + vectors = [ [ 0.1 ], [ 0.2 ], [ 0.3 ] ] + stub_response = { + "data" => [ + { "embedding" => vectors[0], "index" => 0 }, + { "embedding" => vectors[1], "index" => 1 }, + { "embedding" => vectors[2], "index" => 2 } + ] + } + + mock_client = mock("faraday") + mock_client.expects(:post).with("embeddings").yields(mock_request).returns( + OpenStruct.new(body: stub_response) + ) + @host.instance_variable_set(:@embedding_client, mock_client) + + result = @host.embed_batch(texts) + assert_equal vectors, result + end + + test "embed_batch handles multiple batches" do + # Override batch size constant for testing + original = VectorStore::Embeddable::EMBED_BATCH_SIZE + VectorStore::Embeddable.send(:remove_const, :EMBED_BATCH_SIZE) + VectorStore::Embeddable.const_set(:EMBED_BATCH_SIZE, 2) + + texts = [ "a", "b", "c" ] + + batch1_response = { + "data" => [ + { "embedding" => [ 0.1 ], "index" => 0 }, + { "embedding" => [ 0.2 ], "index" => 1 } + ] + } + batch2_response = { + "data" => [ + { "embedding" => [ 0.3 ], "index" => 0 } + ] + } + + mock_client = mock("faraday") + mock_client.expects(:post).with("embeddings").twice + .yields(mock_request) + .returns(OpenStruct.new(body: batch1_response)) + .then.returns(OpenStruct.new(body: batch2_response)) + @host.instance_variable_set(:@embedding_client, mock_client) + + result = @host.embed_batch(texts) + assert_equal [ [ 0.1 ], [ 0.2 ], [ 0.3 ] ], result + ensure + VectorStore::Embeddable.send(:remove_const, :EMBED_BATCH_SIZE) + VectorStore::Embeddable.const_set(:EMBED_BATCH_SIZE, original) + end + + private + + def mock_request + request = OpenStruct.new(body: nil) + request + end +end diff --git a/test/models/vector_store/pgvector_test.rb b/test/models/vector_store/pgvector_test.rb new file mode 100644 index 000000000..e0393f139 --- /dev/null +++ b/test/models/vector_store/pgvector_test.rb @@ -0,0 +1,141 @@ +require "test_helper" + +class VectorStore::PgvectorTest < ActiveSupport::TestCase + setup do + @adapter = VectorStore::Pgvector.new + end + + test "create_store returns a UUID" do + response = @adapter.create_store(name: "Test Store") + assert response.success? + assert_match(/\A[0-9a-f-]{36}\z/, response.data[:id]) + end + + test "delete_store executes delete query" do + mock_conn = mock("connection") + mock_conn.expects(:exec_delete).with( + "DELETE FROM vector_store_chunks WHERE store_id = $1", + "VectorStore::Pgvector DeleteStore", + anything + ).returns(0) + + @adapter.stubs(:connection).returns(mock_conn) + + response = @adapter.delete_store(store_id: "store-123") + assert response.success? + end + + test "upload_file extracts text, chunks, embeds, and inserts" do + file_content = "Hello world" + filename = "test.txt" + store_id = "store-123" + + @adapter.expects(:extract_text).with(file_content, filename).returns("Hello world") + @adapter.expects(:chunk_text).with("Hello world").returns([ "Hello world" ]) + @adapter.expects(:embed_batch).with([ "Hello world" ]).returns([ [ 0.1, 0.2, 0.3 ] ]) + + mock_conn = mock("connection") + mock_conn.expects(:transaction).yields + mock_conn.expects(:exec_insert).once + @adapter.stubs(:connection).returns(mock_conn) + + response = @adapter.upload_file(store_id: store_id, file_content: file_content, filename: filename) + assert response.success? + assert_match(/\A[0-9a-f-]{36}\z/, response.data[:file_id]) + end + + test "upload_file fails when text extraction returns nil" do + @adapter.expects(:extract_text).returns(nil) + + response = @adapter.upload_file(store_id: "store-123", file_content: "\x00binary", filename: "photo.png") + assert_not response.success? + assert_match(/Could not extract text/, response.error.message) + end + + test "upload_file fails when no chunks produced" do + @adapter.expects(:extract_text).returns("some text") + @adapter.expects(:chunk_text).returns([]) + + response = @adapter.upload_file(store_id: "store-123", file_content: "some text", filename: "empty.txt") + assert_not response.success? + assert_match(/No chunks produced/, response.error.message) + end + + test "upload_file inserts multiple chunks in a transaction" do + @adapter.expects(:extract_text).returns("chunk1\n\nchunk2") + @adapter.expects(:chunk_text).returns([ "chunk1", "chunk2" ]) + @adapter.expects(:embed_batch).returns([ [ 0.1 ], [ 0.2 ] ]) + + mock_conn = mock("connection") + mock_conn.expects(:transaction).yields + mock_conn.expects(:exec_insert).twice + @adapter.stubs(:connection).returns(mock_conn) + + response = @adapter.upload_file(store_id: "store-123", file_content: "chunk1\n\nchunk2", filename: "doc.txt") + assert response.success? + end + + test "remove_file executes delete with store_id and file_id" do + mock_conn = mock("connection") + mock_conn.expects(:exec_delete).with( + "DELETE FROM vector_store_chunks WHERE store_id = $1 AND file_id = $2", + "VectorStore::Pgvector RemoveFile", + anything + ).returns(1) + + @adapter.stubs(:connection).returns(mock_conn) + + response = @adapter.remove_file(store_id: "store-123", file_id: "file-456") + assert response.success? + end + + test "search embeds query and returns scored results" do + query_vector = [ 0.1, 0.2, 0.3 ] + @adapter.expects(:embed).with("income").returns(query_vector) + + mock_result = [ + { "content" => "Total income: $85,000", "filename" => "tax_return.pdf", "file_id" => "file-xyz", "score" => 0.95 } + ] + + mock_conn = mock("connection") + mock_conn.expects(:exec_query).returns(mock_result) + @adapter.stubs(:connection).returns(mock_conn) + + response = @adapter.search(store_id: "store-123", query: "income", max_results: 5) + assert response.success? + assert_equal 1, response.data.size + assert_equal "Total income: $85,000", response.data.first[:content] + assert_equal "tax_return.pdf", response.data.first[:filename] + assert_equal 0.95, response.data.first[:score] + assert_equal "file-xyz", response.data.first[:file_id] + end + + test "search returns empty array when no results" do + @adapter.expects(:embed).returns([ 0.1 ]) + + mock_conn = mock("connection") + mock_conn.expects(:exec_query).returns([]) + @adapter.stubs(:connection).returns(mock_conn) + + response = @adapter.search(store_id: "store-123", query: "nothing") + assert response.success? + assert_empty response.data + end + + test "wraps errors in failure response" do + @adapter.expects(:extract_text).raises(StandardError, "unexpected error") + + response = @adapter.upload_file(store_id: "store-123", file_content: "data", filename: "test.txt") + assert_not response.success? + assert_equal "unexpected error", response.error.message + end + + test "supported_extensions matches extractable formats only" do + assert_includes @adapter.supported_extensions, ".pdf" + assert_includes @adapter.supported_extensions, ".txt" + assert_includes @adapter.supported_extensions, ".csv" + assert_not_includes @adapter.supported_extensions, ".png" + assert_not_includes @adapter.supported_extensions, ".zip" + assert_not_includes @adapter.supported_extensions, ".docx" + end +end diff --git a/test/models/vector_store/registry_test.rb b/test/models/vector_store/registry_test.rb index b2d66779e..514b30b24 100644 --- a/test/models/vector_store/registry_test.rb +++ b/test/models/vector_store/registry_test.rb @@ -43,6 +43,13 @@ class VectorStore::RegistryTest < ActiveSupport::TestCase end end + test "adapter returns VectorStore::Pgvector instance when pgvector configured" do + ClimateControl.modify(VECTOR_STORE_PROVIDER: "pgvector") do + adapter = VectorStore::Registry.adapter + assert_instance_of VectorStore::Pgvector, adapter + end + end + test "configured? delegates to adapter presence" do VectorStore::Registry.stubs(:adapter).returns(nil) assert_not VectorStore.configured? diff --git a/test/system/chats_test.rb b/test/system/chats_test.rb index ff4a42f98..379621107 100644 --- a/test/system/chats_test.rb +++ b/test/system/chats_test.rb @@ -32,17 +32,23 @@ class ChatsTest < ApplicationSystemTestCase test "sidebar shows last viewed chat" do with_env_overrides OPENAI_ACCESS_TOKEN: "test-token" do @user.update!(ai_enabled: true) + chat_title = @user.chats.first.title visit root_url - click_on @user.chats.first.title + click_on chat_title + + # Wait for the chat to actually load before refreshing + within "#chat-container" do + assert_selector "h1", text: chat_title + end # Page refresh visit root_url # After page refresh, we're still on the last chat we were viewing within "#chat-container" do - assert_selector "h1", text: @user.chats.first.title + assert_selector "h1", text: chat_title end end end diff --git a/test/system/imports_test.rb b/test/system/imports_test.rb index c39477ac3..15b146e24 100644 --- a/test/system/imports_test.rb +++ b/test/system/imports_test.rb @@ -13,6 +13,7 @@ class ImportsTest < ApplicationSystemTestCase test "transaction import" do visit new_import_path + click_on "Raw Data" click_on "Import transactions" within_testid("import-tabs") do @@ -63,6 +64,7 @@ class ImportsTest < ApplicationSystemTestCase test "trade import" do visit new_import_path + click_on "Raw Data" click_on "Import investments" within_testid("import-tabs") do @@ -105,6 +107,7 @@ class ImportsTest < ApplicationSystemTestCase test "account import" do visit new_import_path + click_on "Raw Data" click_on "Import accounts" within_testid("import-tabs") do @@ -153,6 +156,8 @@ class ImportsTest < ApplicationSystemTestCase test "mint import" do visit new_import_path + # Pending CSV-style imports default the dialog to the Raw Data tab; Mint lives under Financial Tools. + click_on "Financial Tools" click_on "Import from Mint" within_testid("import-tabs") do diff --git a/test/system/settings_test.rb b/test/system/settings_test.rb index 25f2fee70..099e55f6b 100644 --- a/test/system/settings_test.rb +++ b/test/system/settings_test.rb @@ -44,6 +44,7 @@ class SettingsTest < ApplicationSystemTestCase end test "can update self hosting settings" do + sign_in users(:sure_support_staff) Rails.application.config.app_mode.stubs(:self_hosted?).returns(true) Provider::Registry.stubs(:get_provider).with(:twelve_data).returns(nil) Provider::Registry.stubs(:get_provider).with(:yahoo_finance).returns(nil) @@ -59,6 +60,7 @@ class SettingsTest < ApplicationSystemTestCase click_button "Generate new code" assert_selector 'span[data-clipboard-target="source"]', visible: true, count: 1 # invite code copy widget copy_button = find('button[data-action="clipboard#copy"]', match: :first) # Find the first copy button (adjust if needed) + page.execute_script("Object.defineProperty(navigator, 'clipboard', { value: { writeText: () => Promise.resolve() }, writable: true, configurable: true })") # Mock clipboard API due to browser security restrictions in tests copy_button.click assert_selector 'span[data-clipboard-target="iconSuccess"]', visible: true, count: 1 # text copied and icon changed to checkmark end diff --git a/test/system/trades_test.rb b/test/system/trades_test.rb index 8065beab7..f8e94fdc5 100644 --- a/test/system/trades_test.rb +++ b/test/system/trades_test.rb @@ -28,6 +28,8 @@ class TradesTest < ApplicationSystemTestCase click_button "Add transaction" + assert_text "Entry created" + visit_trades within_trades do @@ -49,6 +51,8 @@ class TradesTest < ApplicationSystemTestCase click_button "Add transaction" + assert_text "Entry created" + visit_trades within_trades do diff --git a/test/system/transfers_test.rb b/test/system/transfers_test.rb index a2481b185..6fbd81928 100644 --- a/test/system/transfers_test.rb +++ b/test/system/transfers_test.rb @@ -7,25 +7,44 @@ class TransfersTest < ApplicationSystemTestCase end test "can create a transfer" do - checking_name = accounts(:depository).name - savings_name = accounts(:credit_card).name transfer_date = Date.current click_on "New transaction" - - # Will navigate to different route in same modal click_on "Transfer" assert_text "New transfer" - select checking_name, from: "From" - select savings_name, from: "To" + # Select accounts using DS::Select + select_ds("From", accounts(:depository)) + select_ds("To", accounts(:credit_card)) + fill_in "transfer[amount]", with: 500 fill_in "Date", with: transfer_date click_button "Create transfer" - within "#entry-group-" + transfer_date.to_s do + within "#entry-group-#{transfer_date}" do assert_text "Payment to" end end + + private + + def select_ds(label_text, record) + field_label = find("label", exact_text: label_text) + container = field_label.ancestor("div.relative") + + # Click the button to open the dropdown + container.find("button").click + + # If searchable, type in the search input + if container.has_selector?("input[type='search']", visible: true) + container.find("input[type='search']", visible: true).set(record.name) + end + + # Wait for the listbox to appear inside the relative container + listbox = container.find("[role='listbox']", visible: true) + + # Click the option inside the listbox + listbox.find("[role='option'][data-value='#{record.id}']", visible: true).click + end end diff --git a/test/test_helper.rb b/test/test_helper.rb index afeab9f2e..5af3466ff 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -23,11 +23,26 @@ require "minitest/autorun" require "mocha/minitest" require "aasm/minitest" require "webmock/minitest" +require "uri" VCR.configure do |config| config.cassette_library_dir = "test/vcr_cassettes" config.hook_into :webmock config.ignore_localhost = true + config.ignore_request do |request| + selenium_remote_url = ENV["SELENIUM_REMOTE_URL"] + next false if selenium_remote_url.blank? + + request_uri = URI(request.uri) + selenium_uri = URI(selenium_remote_url) + + request_uri.host.present? && + selenium_uri.host.present? && + request_uri.host == selenium_uri.host && + request_uri.port == selenium_uri.port + rescue URI::InvalidURIError + false + end config.default_cassette_options = { erb: true } config.filter_sensitive_data("") { ENV["OPENAI_ACCESS_TOKEN"] } config.filter_sensitive_data("") { ENV["OPENAI_ORGANIZATION_ID"] } @@ -87,7 +102,6 @@ module ActiveSupport family.categories.find_or_create_by!(name: Category.investment_contributions_name) do |c| c.color = "#0d9488" c.lucide_icon = "trending-up" - c.classification = "expense" end end end