Merge branch 'main' into feature/retirement-planning

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
Juan José Mata
2026-04-07 17:08:35 +02:00
committed by GitHub
867 changed files with 39794 additions and 3666 deletions

View File

@@ -4,7 +4,8 @@
"service": "app",
"runServices": [
"db",
"redis"
"redis",
"selenium"
],
"workspaceFolder": "/workspace",
"containerEnv": {

View File

@@ -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:

View File

@@ -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=

View File

@@ -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

View File

@@ -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

View File

@@ -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

11
.gitignore vendored
View File

@@ -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/

View File

@@ -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

View File

@@ -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.

13
Gemfile
View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -0,0 +1,79 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
<g filter="url(#filter0_i_claw_d)">
<rect width="32" height="32" rx="10" fill="url(#paint0_linear_claw_d)"/>
<rect width="32" height="32" rx="10" fill="white" fill-opacity="0.07" style="mix-blend-mode:plus-lighter"/>
</g>
<g filter="url(#filter1_ii_claw_d)">
<rect x="1.75" y="1.75" width="28.5" height="28.5" rx="8" fill="url(#paint1_linear_claw_d)"/>
</g>
<!-- Lobster/claw icon -->
<!-- Left claw -->
<path d="M9.5 11.5C8.2 10 6 9.8 5.5 11.5C5 13.2 6.5 14.5 8 14.5C8.8 14.5 9.3 14 9.5 13.5" fill="#141414"/>
<path d="M9.5 11.5C8.2 10 6 9.8 5.5 11.5C5 13.2 6.5 14.5 8 14.5C8.8 14.5 9.3 14 9.5 13.5" fill="url(#paint2_linear_claw_d)"/>
<!-- Right claw -->
<path d="M22.5 11.5C23.8 10 26 9.8 26.5 11.5C27 13.2 25.5 14.5 24 14.5C23.2 14.5 22.7 14 22.5 13.5" fill="#141414"/>
<path d="M22.5 11.5C23.8 10 26 9.8 26.5 11.5C27 13.2 25.5 14.5 24 14.5C23.2 14.5 22.7 14 22.5 13.5" fill="url(#paint2_linear_claw_d)"/>
<!-- Left arm -->
<path d="M9.5 13C10 14.5 11 15.5 12 16.5" stroke="#141414" stroke-width="1.5" stroke-linecap="round"/>
<path d="M9.5 13C10 14.5 11 15.5 12 16.5" stroke="url(#paint2_linear_claw_d)" stroke-width="1.5" stroke-linecap="round"/>
<!-- Right arm -->
<path d="M22.5 13C22 14.5 21 15.5 20 16.5" stroke="#141414" stroke-width="1.5" stroke-linecap="round"/>
<path d="M22.5 13C22 14.5 21 15.5 20 16.5" stroke="url(#paint2_linear_claw_d)" stroke-width="1.5" stroke-linecap="round"/>
<!-- Body -->
<path d="M16 13C13 13 11 15 11 17.5C11 20 12.5 22 14.5 23L13.5 25.5C13.3 26 13.6 26.5 14 26.5C14.4 26.5 14.7 26.2 14.8 25.8L15.5 23.8C15.7 23.8 16 23.9 16 23.9C16 23.9 16.3 23.8 16.5 23.8L17.2 25.8C17.3 26.2 17.6 26.5 18 26.5C18.4 26.5 18.7 26 18.5 25.5L17.5 23C19.5 22 21 20 21 17.5C21 15 19 13 16 13Z" fill="#141414"/>
<path d="M16 13C13 13 11 15 11 17.5C11 20 12.5 22 14.5 23L13.5 25.5C13.3 26 13.6 26.5 14 26.5C14.4 26.5 14.7 26.2 14.8 25.8L15.5 23.8C15.7 23.8 16 23.9 16 23.9C16 23.9 16.3 23.8 16.5 23.8L17.2 25.8C17.3 26.2 17.6 26.5 18 26.5C18.4 26.5 18.7 26 18.5 25.5L17.5 23C19.5 22 21 20 21 17.5C21 15 19 13 16 13Z" fill="url(#paint2_linear_claw_d)"/>
<!-- Eyes -->
<circle cx="14" cy="16.5" r="1" fill="white"/>
<circle cx="18" cy="16.5" r="1" fill="white"/>
<circle cx="14" cy="16.5" r="0.5" fill="#141414"/>
<circle cx="18" cy="16.5" r="0.5" fill="#141414"/>
<!-- Antennae -->
<path d="M14 13L12.5 9.5" stroke="#141414" stroke-width="1" stroke-linecap="round"/>
<path d="M14 13L12.5 9.5" stroke="url(#paint2_linear_claw_d)" stroke-width="1" stroke-linecap="round"/>
<path d="M18 13L19.5 9.5" stroke="#141414" stroke-width="1" stroke-linecap="round"/>
<path d="M18 13L19.5 9.5" stroke="url(#paint2_linear_claw_d)" stroke-width="1" stroke-linecap="round"/>
<defs>
<filter id="filter0_i_claw_d" x="0" y="0" width="32" height="32" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="0.49869"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_claw_d"/>
</filter>
<filter id="filter1_ii_claw_d" x="1.75" y="0.75" width="28.5" height="30.5" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-1"/>
<feGaussianBlur stdDeviation="1"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.92 0 0 0 0 0.16 0 0 0 0 0.16 0 0 0 0.2 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_claw_d"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="1"/>
<feGaussianBlur stdDeviation="1"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.45 0 0 0 0 0.2 0 0 0 0.15 0"/>
<feBlend mode="normal" in2="effect1_innerShadow_claw_d" result="effect2_innerShadow_claw_d"/>
</filter>
<linearGradient id="paint0_linear_claw_d" x1="16" y1="0" x2="16" y2="32" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF6B35"/>
<stop offset="0.35" stop-color="#EF0011"/>
<stop offset="0.7" stop-color="#C50010"/>
<stop offset="1" stop-color="#8B0000"/>
</linearGradient>
<linearGradient id="paint1_linear_claw_d" x1="16" y1="10.6562" x2="16" y2="30.25" gradientUnits="userSpaceOnUse">
<stop stop-color="#171717"/>
<stop offset="0.3" stop-color="#0B0B0B"/>
</linearGradient>
<linearGradient id="paint2_linear_claw_d" x1="17.8739" y1="7.84186" x2="13.7328" y2="23.2967" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF6B35"/>
<stop offset="0.35" stop-color="#EF0011"/>
<stop offset="0.7" stop-color="#C50010"/>
<stop offset="1" stop-color="#8B0000"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@@ -0,0 +1,79 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
<g filter="url(#filter0_i_claw)">
<rect width="32" height="32" rx="10" fill="url(#paint0_linear_claw)"/>
<rect width="32" height="32" rx="10" fill="white" fill-opacity="0.07" style="mix-blend-mode:plus-lighter"/>
</g>
<g filter="url(#filter1_ii_claw)">
<rect x="1.75" y="1.75" width="28.5" height="28.5" rx="8" fill="url(#paint1_linear_claw)"/>
</g>
<!-- Lobster/claw icon -->
<!-- Left claw -->
<path d="M9.5 11.5C8.2 10 6 9.8 5.5 11.5C5 13.2 6.5 14.5 8 14.5C8.8 14.5 9.3 14 9.5 13.5" fill="#141414"/>
<path d="M9.5 11.5C8.2 10 6 9.8 5.5 11.5C5 13.2 6.5 14.5 8 14.5C8.8 14.5 9.3 14 9.5 13.5" fill="url(#paint2_linear_claw)"/>
<!-- Right claw -->
<path d="M22.5 11.5C23.8 10 26 9.8 26.5 11.5C27 13.2 25.5 14.5 24 14.5C23.2 14.5 22.7 14 22.5 13.5" fill="#141414"/>
<path d="M22.5 11.5C23.8 10 26 9.8 26.5 11.5C27 13.2 25.5 14.5 24 14.5C23.2 14.5 22.7 14 22.5 13.5" fill="url(#paint2_linear_claw)"/>
<!-- Left arm -->
<path d="M9.5 13C10 14.5 11 15.5 12 16.5" stroke="#141414" stroke-width="1.5" stroke-linecap="round"/>
<path d="M9.5 13C10 14.5 11 15.5 12 16.5" stroke="url(#paint2_linear_claw)" stroke-width="1.5" stroke-linecap="round"/>
<!-- Right arm -->
<path d="M22.5 13C22 14.5 21 15.5 20 16.5" stroke="#141414" stroke-width="1.5" stroke-linecap="round"/>
<path d="M22.5 13C22 14.5 21 15.5 20 16.5" stroke="url(#paint2_linear_claw)" stroke-width="1.5" stroke-linecap="round"/>
<!-- Body -->
<path d="M16 13C13 13 11 15 11 17.5C11 20 12.5 22 14.5 23L13.5 25.5C13.3 26 13.6 26.5 14 26.5C14.4 26.5 14.7 26.2 14.8 25.8L15.5 23.8C15.7 23.8 16 23.9 16 23.9C16 23.9 16.3 23.8 16.5 23.8L17.2 25.8C17.3 26.2 17.6 26.5 18 26.5C18.4 26.5 18.7 26 18.5 25.5L17.5 23C19.5 22 21 20 21 17.5C21 15 19 13 16 13Z" fill="#141414"/>
<path d="M16 13C13 13 11 15 11 17.5C11 20 12.5 22 14.5 23L13.5 25.5C13.3 26 13.6 26.5 14 26.5C14.4 26.5 14.7 26.2 14.8 25.8L15.5 23.8C15.7 23.8 16 23.9 16 23.9C16 23.9 16.3 23.8 16.5 23.8L17.2 25.8C17.3 26.2 17.6 26.5 18 26.5C18.4 26.5 18.7 26 18.5 25.5L17.5 23C19.5 22 21 20 21 17.5C21 15 19 13 16 13Z" fill="url(#paint2_linear_claw)"/>
<!-- Eyes -->
<circle cx="14" cy="16.5" r="1" fill="white"/>
<circle cx="18" cy="16.5" r="1" fill="white"/>
<circle cx="14" cy="16.5" r="0.5" fill="#141414"/>
<circle cx="18" cy="16.5" r="0.5" fill="#141414"/>
<!-- Antennae -->
<path d="M14 13L12.5 9.5" stroke="#141414" stroke-width="1" stroke-linecap="round"/>
<path d="M14 13L12.5 9.5" stroke="url(#paint2_linear_claw)" stroke-width="1" stroke-linecap="round"/>
<path d="M18 13L19.5 9.5" stroke="#141414" stroke-width="1" stroke-linecap="round"/>
<path d="M18 13L19.5 9.5" stroke="url(#paint2_linear_claw)" stroke-width="1" stroke-linecap="round"/>
<defs>
<filter id="filter0_i_claw" x="0" y="0" width="32" height="32" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="0.49869"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_claw"/>
</filter>
<filter id="filter1_ii_claw" x="1.75" y="0.861111" width="28.5" height="30.2778" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-0.888889"/>
<feGaussianBlur stdDeviation="0.888889"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.92 0 0 0 0 0.16 0 0 0 0 0.16 0 0 0 0.1 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_claw"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="0.888889"/>
<feGaussianBlur stdDeviation="0.888889"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.45 0 0 0 0 0.2 0 0 0 0.1 0"/>
<feBlend mode="normal" in2="effect1_innerShadow_claw" result="effect2_innerShadow_claw"/>
</filter>
<linearGradient id="paint0_linear_claw" x1="16" y1="0" x2="16" y2="32" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF6B35"/>
<stop offset="0.35" stop-color="#EF0011"/>
<stop offset="0.7" stop-color="#C50010"/>
<stop offset="1" stop-color="#8B0000"/>
</linearGradient>
<linearGradient id="paint1_linear_claw" x1="16" y1="10.6562" x2="16" y2="30.25" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="0.3" stop-color="#F7F7F7"/>
</linearGradient>
<linearGradient id="paint2_linear_claw" x1="17.8739" y1="7.84186" x2="13.7328" y2="23.2967" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF6B35"/>
<stop offset="0.35" stop-color="#EF0011"/>
<stop offset="0.7" stop-color="#C50010"/>
<stop offset="1" stop-color="#8B0000"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@@ -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{

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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 %>
<div class="grow overflow-y-auto py-4 space-y-4 flex flex-col">
<div class="grow py-4 space-y-4 flex flex-col <%= "overflow-auto" if scrollable %>">
<% if header? %>
<%= header %>
<% end %>

View File

@@ -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
)

View File

@@ -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 %>
<div data-DS--menu-target="content" class="px-2 lg:px-0 max-w-full hidden z-50">
<div class="mx-auto min-w-[200px] shadow-border-xs bg-container rounded-lg">
<%= tag.div class: "mx-auto min-w-[200px] shadow-border-xs bg-container rounded-lg", style: ("max-width: #{max_width}" if max_width) do %>
<%= header %>
<%= tag.div class: class_names("py-1" => !no_padding) do %>
@@ -22,6 +22,6 @@
<%= custom_content %>
<% end %>
</div>
<% end %>
</div>
<% end %>

View File

@@ -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

View File

@@ -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",

View File

@@ -0,0 +1,94 @@
<%# locals: form:, method:, collection:, options: {} %>
<div class="relative" data-controller="select <%= "list-filter" if searchable %> form-dropdown" data-action="dropdown:select->form-dropdown#onSelect">
<div class="form-field <%= options[:container_class] %>">
<div class="form-field__body">
<%= 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"
} %>
<button type="button"
class="form-field__input w-full"
data-select-target="button"
data-action="click->select#toggle"
aria-haspopup="listbox"
aria-expanded="<%= @selected_value.present? ? "true" : "false" %>"
aria-labelledby="<%= "#{method}_label" %>">
<%= selected_item&.dig(:label) || @placeholder %>
</button>
</div>
</div>
<div class="absolute z-50 p-1.5 w-full min-w-32 rounded-lg shadow-lg shadow-border-xs bg-container mt-1.5 transition duration-150 ease-out -translate-y-1 opacity-0 hidden" data-select-target="menu">
<% if searchable %>
<div class="relative flex items-center bg-container border border-secondary rounded-lg mb-1">
<input type="search"
placeholder="<%= t("helpers.select.search_placeholder") %>"
autocomplete="off"
class="bg-container text-sm placeholder:text-secondary font-normal h-10 pl-10 w-full border-none rounded-lg focus:outline-hidden focus:ring-0"
data-list-filter-target="input"
data-action="list-filter#filter">
<%= helpers.icon("search", class: "absolute inset-0 ml-2 transform top-1/2 -translate-y-1/2") %>
</div>
<% end %>
<div data-list-filter-target="list" data-select-target="content" class="flex flex-col gap-0.5 max-h-64 overflow-auto"
role="listbox" tabindex="-1">
<% items.each do |item| %>
<% is_selected = item[:value] == selected_value %>
<% obj = item[:object] %>
<div class="filterable-item text-sm cursor-pointer flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-container-inset-hover <%= "bg-container-inset" if is_selected %>"
role="option"
tabindex="0"
aria-selected="<%= is_selected %>"
data-action="click->select#select"
data-value="<%= item[:value] %>"
data-filter-name="<%= item[:label] %>">
<span class="check-icon <%= "hidden" unless is_selected %>">
<%= helpers.icon("check") %>
</span>
<% case variant %>
<% when :simple %>
<%= item[:label] %>
<% when :logo %>
<% unless item[:value].nil? %>
<% if logo_for(item) %>
<%= image_tag logo_for(item),
class: "w-6 h-6 rounded-full border border-secondary",
loading: "lazy" %>
<% else %>
<%= render DS::FilledIcon.new(
variant: :text,
text: item[:label],
size: "sm",
rounded: true
) %>
<% end %>
<% end %>
<%= item[:label] %>
<% when :badge %>
<% hex_color = color_for(item) %>
<span class="flex items-center gap-2 text-sm font-medium rounded-full px-3 py-1 border truncate"
style="
background-color: color-mix(in oklab, <%= hex_color %> 10%, transparent);
border-color: color-mix(in oklab, <%= hex_color %> 20%, transparent);
color: <%= hex_color %>;">
<% if icon_for(item) %>
<%= helpers.icon icon_for(item), size: "sm", color: "current" %>
<% else %>
<span class="size-1.5 rounded-full" style="background-color: <%= hex_color %>;"></span>
<% end %>
<%= item[:label] %>
</span>
<% end %>
</div>
<% end %>
</div>
</div>
</div>

View File

@@ -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

View File

@@ -9,10 +9,10 @@
<% end %>
</div>
<div class="flex flex-row gap-2 items-baseline">
<%= 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 %>
</div>
</div>
@@ -38,14 +38,14 @@
<%= turbo_frame_tag dom_id(@account, :chart_details) do %>
<div class="px-4">
<%= 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 } %>
</div>
<div class="h-64 pb-4">
<% if series.any? %>
<div
id="lineChart"
class="w-full h-full"
class="w-full h-full privacy-sensitive"
data-controller="time-series-chart"
data-time-series-chart-data-value="<%= series.to_json %>"></div>
<% else %>

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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(

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -22,10 +22,15 @@ module Api
# @return [Array<Hash>] 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])

View File

@@ -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]

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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? }

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -1,3 +1,5 @@
class InvestmentsController < ApplicationController
include AccountableResource
permitted_accountable_attributes :id, :subtype
end

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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!

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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")

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"
},

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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?)

View File

@@ -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

View File

@@ -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(

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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?

View File

@@ -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).
#

View File

@@ -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

View File

@@ -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]

View File

@@ -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",

View File

@@ -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? },

View File

@@ -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 = {})

View File

@@ -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()
}
}
}
}

View File

@@ -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"
}
}
}

Some files were not shown because too many files have changed in this diff Show More