mirror of
https://github.com/we-promise/sure.git
synced 2026-05-30 07:49:01 +00:00
Merge branch 'main' into feature/retirement-planning
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
@@ -4,7 +4,8 @@
|
||||
"service": "app",
|
||||
"runServices": [
|
||||
"db",
|
||||
"redis"
|
||||
"redis",
|
||||
"selenium"
|
||||
],
|
||||
"workspaceFolder": "/workspace",
|
||||
"containerEnv": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
16
.env.example
16
.env.example
@@ -25,6 +25,21 @@ OPENAI_ACCESS_TOKEN=
|
||||
OPENAI_MODEL=
|
||||
OPENAI_URI_BASE=
|
||||
|
||||
# Optional: External AI Assistant — delegates chat to a remote AI agent
|
||||
# instead of calling LLMs directly. The agent calls back to Sure's /mcp endpoint.
|
||||
# See docs/hosting/ai.md for full details.
|
||||
# ASSISTANT_TYPE=external
|
||||
# EXTERNAL_ASSISTANT_URL=https://your-agent-host/v1/chat/completions
|
||||
# EXTERNAL_ASSISTANT_TOKEN=your-api-token
|
||||
# EXTERNAL_ASSISTANT_AGENT_ID=main
|
||||
# EXTERNAL_ASSISTANT_SESSION_KEY=agent:main:main
|
||||
# EXTERNAL_ASSISTANT_ALLOWED_EMAILS=user@example.com
|
||||
|
||||
# Optional: MCP server endpoint — enables /mcp for external AI assistants.
|
||||
# Both values are required. MCP_USER_EMAIL must match an existing user's email.
|
||||
# MCP_API_TOKEN=your-random-bearer-token
|
||||
# MCP_USER_EMAIL=user@example.com
|
||||
|
||||
# Optional: Langfuse config
|
||||
LANGFUSE_HOST=https://cloud.langfuse.com
|
||||
LANGFUSE_PUBLIC_KEY=
|
||||
@@ -59,6 +74,7 @@ SMTP_PORT=465
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_TLS_ENABLED=true
|
||||
SMTP_TLS_SKIP_VERIFY=false
|
||||
|
||||
# Address that emails are sent from
|
||||
EMAIL_SENDER=
|
||||
|
||||
4
.github/copilot-instructions.md
vendored
4
.github/copilot-instructions.md
vendored
@@ -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
|
||||
|
||||
|
||||
9
.github/workflows/pipelock.yml
vendored
9
.github/workflows/pipelock.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/update-docs.yml
vendored
2
.github/workflows/update-docs.yml
vendored
@@ -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
11
.gitignore
vendored
@@ -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/
|
||||
13
CLAUDE.md
13
CLAUDE.md
@@ -12,10 +12,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
### Testing
|
||||
- `bin/rails test` - Run all tests
|
||||
- `bin/rails test:db` - Run tests with database reset
|
||||
- `bin/rails test:system` - Run system tests only (use sparingly - they take longer)
|
||||
- `DISABLE_PARALLELIZATION=true bin/rails test:system` - Run system tests only (use sparingly - they take longer)
|
||||
- `bin/rails test test/models/account_test.rb` - Run specific test file
|
||||
- `bin/rails test test/models/account_test.rb:42` - Run specific test at line
|
||||
|
||||
#### System Tests in the Dev Container
|
||||
When running inside the Dev Container, the `SELENIUM_REMOTE_URL` environment variable is automatically set to the bundled `selenium/standalone-chromium` service. System tests will connect to that remote browser — no local Chrome installation is required.
|
||||
|
||||
```bash
|
||||
DISABLE_PARALLELIZATION=true bin/rails test:system
|
||||
```
|
||||
|
||||
To watch the browser live, open `http://localhost:7900` or `http://localhost:4444` in your host browser (password: `secret`).
|
||||
|
||||
### Linting & Formatting
|
||||
- `bin/rubocop` - Run Ruby linter
|
||||
- `npm run lint` - Check JavaScript/TypeScript code
|
||||
@@ -38,7 +47,7 @@ ALWAYS run these commands before opening a pull request:
|
||||
|
||||
1. **Tests** (Required):
|
||||
- `bin/rails test` - Run all tests (always required)
|
||||
- `bin/rails test:system` - Run system tests (only when applicable, they take longer)
|
||||
- `DISABLE_PARALLELIZATION=true bin/rails test:system` - Run system tests (only when applicable, they take longer)
|
||||
|
||||
2. **Linting** (Required):
|
||||
- `bin/rubocop -f github -a` - Ruby linting with auto-correct
|
||||
|
||||
@@ -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
13
Gemfile
@@ -31,6 +31,7 @@ gem "lookbook", "2.3.11"
|
||||
gem "hotwire_combobox"
|
||||
|
||||
# Background Jobs
|
||||
gem "connection_pool", "~> 2.5" # pin to 2.x; 3.0 breaks sidekiq 8.x
|
||||
gem "sidekiq"
|
||||
gem "sidekiq-cron"
|
||||
gem "sidekiq-unique-jobs"
|
||||
@@ -68,6 +69,7 @@ gem "faraday-multipart"
|
||||
gem "inline_svg"
|
||||
gem "octokit"
|
||||
gem "pagy"
|
||||
gem "rails-i18n"
|
||||
gem "rails-settings-cached"
|
||||
gem "tzinfo-data", platforms: %i[windows jruby]
|
||||
gem "csv"
|
||||
@@ -124,6 +126,13 @@ group :development do
|
||||
gem "foreman"
|
||||
end
|
||||
|
||||
group :development, :test do
|
||||
gem "rspec-rails"
|
||||
gem "rswag-api"
|
||||
gem "rswag-specs"
|
||||
gem "rswag-ui"
|
||||
end
|
||||
|
||||
group :test do
|
||||
gem "capybara"
|
||||
gem "selenium-webdriver"
|
||||
@@ -132,8 +141,4 @@ group :test do
|
||||
gem "webmock"
|
||||
gem "climate_control"
|
||||
gem "simplecov", require: false
|
||||
gem "rspec-rails"
|
||||
gem "rswag-api"
|
||||
gem "rswag-specs"
|
||||
gem "rswag-ui"
|
||||
end
|
||||
|
||||
172
Gemfile.lock
172
Gemfile.lock
@@ -4,68 +4,70 @@ GEM
|
||||
Ascii85 (2.0.1)
|
||||
aasm (5.5.1)
|
||||
concurrent-ruby (~> 1.0)
|
||||
actioncable (7.2.2.2)
|
||||
actionpack (= 7.2.2.2)
|
||||
activesupport (= 7.2.2.2)
|
||||
actioncable (7.2.3.1)
|
||||
actionpack (= 7.2.3.1)
|
||||
activesupport (= 7.2.3.1)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (7.2.2.2)
|
||||
actionpack (= 7.2.2.2)
|
||||
activejob (= 7.2.2.2)
|
||||
activerecord (= 7.2.2.2)
|
||||
activestorage (= 7.2.2.2)
|
||||
activesupport (= 7.2.2.2)
|
||||
actionmailbox (7.2.3.1)
|
||||
actionpack (= 7.2.3.1)
|
||||
activejob (= 7.2.3.1)
|
||||
activerecord (= 7.2.3.1)
|
||||
activestorage (= 7.2.3.1)
|
||||
activesupport (= 7.2.3.1)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (7.2.2.2)
|
||||
actionpack (= 7.2.2.2)
|
||||
actionview (= 7.2.2.2)
|
||||
activejob (= 7.2.2.2)
|
||||
activesupport (= 7.2.2.2)
|
||||
actionmailer (7.2.3.1)
|
||||
actionpack (= 7.2.3.1)
|
||||
actionview (= 7.2.3.1)
|
||||
activejob (= 7.2.3.1)
|
||||
activesupport (= 7.2.3.1)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (7.2.2.2)
|
||||
actionview (= 7.2.2.2)
|
||||
activesupport (= 7.2.2.2)
|
||||
actionpack (7.2.3.1)
|
||||
actionview (= 7.2.3.1)
|
||||
activesupport (= 7.2.3.1)
|
||||
cgi
|
||||
nokogiri (>= 1.8.5)
|
||||
racc
|
||||
rack (>= 2.2.4, < 3.2)
|
||||
rack (>= 2.2.4, < 3.3)
|
||||
rack-session (>= 1.0.1)
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (7.2.2.2)
|
||||
actionpack (= 7.2.2.2)
|
||||
activerecord (= 7.2.2.2)
|
||||
activestorage (= 7.2.2.2)
|
||||
activesupport (= 7.2.2.2)
|
||||
actiontext (7.2.3.1)
|
||||
actionpack (= 7.2.3.1)
|
||||
activerecord (= 7.2.3.1)
|
||||
activestorage (= 7.2.3.1)
|
||||
activesupport (= 7.2.3.1)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (7.2.2.2)
|
||||
activesupport (= 7.2.2.2)
|
||||
actionview (7.2.3.1)
|
||||
activesupport (= 7.2.3.1)
|
||||
builder (~> 3.1)
|
||||
cgi
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (7.2.2.2)
|
||||
activesupport (= 7.2.2.2)
|
||||
activejob (7.2.3.1)
|
||||
activesupport (= 7.2.3.1)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (7.2.2.2)
|
||||
activesupport (= 7.2.2.2)
|
||||
activerecord (7.2.2.2)
|
||||
activemodel (= 7.2.2.2)
|
||||
activesupport (= 7.2.2.2)
|
||||
activemodel (7.2.3.1)
|
||||
activesupport (= 7.2.3.1)
|
||||
activerecord (7.2.3.1)
|
||||
activemodel (= 7.2.3.1)
|
||||
activesupport (= 7.2.3.1)
|
||||
timeout (>= 0.4.0)
|
||||
activerecord-import (2.2.0)
|
||||
activerecord (>= 4.2)
|
||||
activestorage (7.2.2.2)
|
||||
actionpack (= 7.2.2.2)
|
||||
activejob (= 7.2.2.2)
|
||||
activerecord (= 7.2.2.2)
|
||||
activesupport (= 7.2.2.2)
|
||||
activestorage (7.2.3.1)
|
||||
actionpack (= 7.2.3.1)
|
||||
activejob (= 7.2.3.1)
|
||||
activerecord (= 7.2.3.1)
|
||||
activesupport (= 7.2.3.1)
|
||||
marcel (~> 1.0)
|
||||
activesupport (7.2.2.2)
|
||||
activesupport (7.2.3.1)
|
||||
base64
|
||||
benchmark (>= 0.3)
|
||||
bigdecimal
|
||||
@@ -74,7 +76,7 @@ GEM
|
||||
drb
|
||||
i18n (>= 1.6, < 2)
|
||||
logger (>= 1.4.2)
|
||||
minitest (>= 5.1)
|
||||
minitest (>= 5.1, < 6)
|
||||
securerandom (>= 0.3)
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
addressable (2.8.7)
|
||||
@@ -106,8 +108,8 @@ GEM
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.20)
|
||||
benchmark (0.4.1)
|
||||
bcrypt (3.1.22)
|
||||
benchmark (0.5.0)
|
||||
benchmark-ips (2.14.0)
|
||||
better_html (2.1.1)
|
||||
actionview (>= 6.0)
|
||||
@@ -133,12 +135,13 @@ GEM
|
||||
rack-test (>= 0.6.3)
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
cgi (0.5.1)
|
||||
childprocess (5.1.0)
|
||||
logger (~> 1.5)
|
||||
chunky_png (1.4.0)
|
||||
climate_control (1.2.0)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.3)
|
||||
concurrent-ruby (1.3.6)
|
||||
connection_pool (2.5.5)
|
||||
countries (8.0.3)
|
||||
unaccent (~> 0.3)
|
||||
crack (1.0.0)
|
||||
@@ -226,7 +229,7 @@ GEM
|
||||
get_process_mem (1.0.0)
|
||||
bigdecimal (>= 2.0)
|
||||
ffi (~> 1.0)
|
||||
globalid (1.2.1)
|
||||
globalid (1.3.0)
|
||||
activesupport (>= 6.1)
|
||||
hashdiff (1.2.0)
|
||||
hashery (2.1.2)
|
||||
@@ -250,7 +253,7 @@ GEM
|
||||
csv
|
||||
mini_mime (>= 1.0.0)
|
||||
multi_xml (>= 0.5.2)
|
||||
i18n (1.14.7)
|
||||
i18n (1.14.8)
|
||||
concurrent-ruby (~> 1.0)
|
||||
i18n-tasks (1.0.15)
|
||||
activesupport (>= 4.0.2)
|
||||
@@ -282,7 +285,7 @@ GEM
|
||||
actionview (>= 5.0.0)
|
||||
activesupport (>= 5.0.0)
|
||||
jmespath (1.6.2)
|
||||
json (2.18.1)
|
||||
json (2.19.2)
|
||||
json-jwt (1.16.7)
|
||||
activesupport (>= 4.2)
|
||||
aes_key_wrap
|
||||
@@ -323,7 +326,7 @@ GEM
|
||||
logtail (~> 0.1, >= 0.1.14)
|
||||
logtail-rack (~> 0.1)
|
||||
railties (>= 5.0.0)
|
||||
loofah (2.24.1)
|
||||
loofah (2.25.1)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
lookbook (2.3.11)
|
||||
@@ -345,7 +348,7 @@ GEM
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
marcel (1.0.4)
|
||||
marcel (1.1.0)
|
||||
matrix (0.4.2)
|
||||
memory_profiler (1.1.0)
|
||||
method_source (1.1.0)
|
||||
@@ -354,7 +357,7 @@ GEM
|
||||
benchmark
|
||||
logger
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.25.5)
|
||||
minitest (5.27.0)
|
||||
mocha (2.7.1)
|
||||
ruby2_keywords (>= 0.0.5)
|
||||
msgpack (1.8.0)
|
||||
@@ -374,21 +377,21 @@ GEM
|
||||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.19.1-aarch64-linux-gnu)
|
||||
nokogiri (1.19.2-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.1-aarch64-linux-musl)
|
||||
nokogiri (1.19.2-aarch64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.1-arm-linux-gnu)
|
||||
nokogiri (1.19.2-arm-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.1-arm-linux-musl)
|
||||
nokogiri (1.19.2-arm-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.1-arm64-darwin)
|
||||
nokogiri (1.19.2-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.1-x86_64-darwin)
|
||||
nokogiri (1.19.2-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.1-x86_64-linux-gnu)
|
||||
nokogiri (1.19.2-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.1-x86_64-linux-musl)
|
||||
nokogiri (1.19.2-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
oauth2 (2.0.18)
|
||||
faraday (>= 0.17.3, < 4.0)
|
||||
@@ -441,7 +444,7 @@ GEM
|
||||
ostruct (0.6.2)
|
||||
pagy (9.3.5)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.8.0)
|
||||
parser (3.3.10.2)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pdf-reader (2.15.1)
|
||||
@@ -478,7 +481,7 @@ GEM
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.1.20)
|
||||
rack (3.2.6)
|
||||
rack-attack (6.7.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-cors (3.0.0)
|
||||
@@ -504,26 +507,26 @@ GEM
|
||||
rack (>= 1.3)
|
||||
rackup (2.2.1)
|
||||
rack (>= 3)
|
||||
rails (7.2.2.2)
|
||||
actioncable (= 7.2.2.2)
|
||||
actionmailbox (= 7.2.2.2)
|
||||
actionmailer (= 7.2.2.2)
|
||||
actionpack (= 7.2.2.2)
|
||||
actiontext (= 7.2.2.2)
|
||||
actionview (= 7.2.2.2)
|
||||
activejob (= 7.2.2.2)
|
||||
activemodel (= 7.2.2.2)
|
||||
activerecord (= 7.2.2.2)
|
||||
activestorage (= 7.2.2.2)
|
||||
activesupport (= 7.2.2.2)
|
||||
rails (7.2.3.1)
|
||||
actioncable (= 7.2.3.1)
|
||||
actionmailbox (= 7.2.3.1)
|
||||
actionmailer (= 7.2.3.1)
|
||||
actionpack (= 7.2.3.1)
|
||||
actiontext (= 7.2.3.1)
|
||||
actionview (= 7.2.3.1)
|
||||
activejob (= 7.2.3.1)
|
||||
activemodel (= 7.2.3.1)
|
||||
activerecord (= 7.2.3.1)
|
||||
activestorage (= 7.2.3.1)
|
||||
activesupport (= 7.2.3.1)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 7.2.2.2)
|
||||
railties (= 7.2.3.1)
|
||||
rails-dom-testing (2.3.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.6.2)
|
||||
loofah (~> 2.21)
|
||||
rails-html-sanitizer (1.7.0)
|
||||
loofah (~> 2.25)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
rails-i18n (7.0.10)
|
||||
i18n (>= 0.7, < 2)
|
||||
@@ -531,13 +534,15 @@ GEM
|
||||
rails-settings-cached (2.9.6)
|
||||
activerecord (>= 5.0.0)
|
||||
railties (>= 5.0.0)
|
||||
railties (7.2.2.2)
|
||||
actionpack (= 7.2.2.2)
|
||||
activesupport (= 7.2.2.2)
|
||||
railties (7.2.3.1)
|
||||
actionpack (= 7.2.3.1)
|
||||
activesupport (= 7.2.3.1)
|
||||
cgi
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0, >= 1.2.2)
|
||||
tsort (>= 0.2)
|
||||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.3.0)
|
||||
@@ -621,13 +626,12 @@ GEM
|
||||
rubocop (>= 1.72)
|
||||
rubocop-performance (>= 1.24)
|
||||
rubocop-rails (>= 2.30)
|
||||
ruby-lsp (0.24.1)
|
||||
ruby-lsp (0.26.9)
|
||||
language_server-protocol (~> 3.17.0)
|
||||
prism (>= 1.2, < 2.0)
|
||||
rbs (>= 3, < 5)
|
||||
sorbet-runtime (>= 0.5.10782)
|
||||
ruby-lsp-rails (0.4.6)
|
||||
ruby-lsp (>= 0.24.0, < 0.25.0)
|
||||
ruby-lsp-rails (0.4.8)
|
||||
ruby-lsp (>= 0.26.0, < 0.27.0)
|
||||
ruby-openai (8.1.0)
|
||||
event_stream_parser (>= 0.3.0, < 2.0.0)
|
||||
faraday (>= 1)
|
||||
@@ -692,7 +696,6 @@ GEM
|
||||
snaptrade (2.0.156)
|
||||
faraday (>= 1.0.1, < 3.0)
|
||||
faraday-multipart (~> 1.0, >= 1.0.4)
|
||||
sorbet-runtime (0.5.12163)
|
||||
stackprof (0.2.27)
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
@@ -716,7 +719,8 @@ GEM
|
||||
terminal-table (4.0.0)
|
||||
unicode-display_width (>= 1.1.1, < 4)
|
||||
thor (1.4.0)
|
||||
timeout (0.4.3)
|
||||
timeout (0.6.1)
|
||||
tsort (0.2.0)
|
||||
ttfunk (1.8.0)
|
||||
bigdecimal (~> 3.1)
|
||||
turbo-rails (2.0.16)
|
||||
@@ -786,6 +790,7 @@ DEPENDENCIES
|
||||
brakeman
|
||||
capybara
|
||||
climate_control
|
||||
connection_pool (~> 2.5)
|
||||
countries
|
||||
csv
|
||||
debug
|
||||
@@ -834,6 +839,7 @@ DEPENDENCIES
|
||||
rack-cors
|
||||
rack-mini-profiler
|
||||
rails (~> 7.2.2)
|
||||
rails-i18n
|
||||
rails-settings-cached
|
||||
rchardet
|
||||
redcarpet
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
79
app/assets/images/claw-dark.svg
Normal file
79
app/assets/images/claw-dark.svg
Normal 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 |
79
app/assets/images/claw.svg
Normal file
79
app/assets/images/claw.svg
Normal 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 |
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
11
app/assets/tailwind/privacy-mode.css
Normal file
11
app/assets/tailwind/privacy-mode.css
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
94
app/components/DS/select.html.erb
Normal file
94
app/components/DS/select.html.erb
Normal 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>
|
||||
83
app/components/DS/select.rb
Normal file
83
app/components/DS/select.rb
Normal 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
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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
|
||||
|
||||
62
app/controllers/account_sharings_controller.rb
Normal file
62
app/controllers/account_sharings_controller.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
17
app/controllers/admin/invitations_controller.rb
Normal file
17
app/controllers/admin/invitations_controller.rb
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
27
app/controllers/api/v1/balance_sheet_controller.rb
Normal file
27
app/controllers/api/v1/balance_sheet_controller.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
13
app/controllers/archived_exports_controller.rb
Normal file
13
app/controllers/archived_exports_controller.rb
Normal 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
|
||||
287
app/controllers/binance_items_controller.rb
Normal file
287
app/controllers/binance_items_controller.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
29
app/controllers/concerns/account_authorizable.rb
Normal file
29
app/controllers/concerns/account_authorizable.rb
Normal 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
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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? }
|
||||
|
||||
@@ -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
|
||||
|
||||
81
app/controllers/import/qif_category_selections_controller.rb
Normal file
81
app/controllers/import/qif_category_selections_controller.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
class InvestmentsController < ApplicationController
|
||||
include AccountableResource
|
||||
|
||||
permitted_accountable_attributes :id, :subtype
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!
|
||||
|
||||
84
app/controllers/pending_duplicate_merges_controller.rb
Normal file
84
app/controllers/pending_duplicate_merges_controller.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
19
app/controllers/settings/appearances_controller.rb
Normal file
19
app/controllers/settings/appearances_controller.rb
Normal 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
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
100
app/controllers/splits_controller.rb
Normal file
100
app/controllers/splits_controller.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
118
app/controllers/transaction_attachments_controller.rb
Normal file
118
app/controllers/transaction_attachments_controller.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?)
|
||||
|
||||
|
||||
134
app/controllers/transactions/categorizes_controller.rb
Normal file
134
app/controllers/transactions/categorizes_controller.rb
Normal 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
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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).
|
||||
#
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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? },
|
||||
|
||||
@@ -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 = {})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
63
app/javascript/controllers/attachment_upload_controller.js
Normal file
63
app/javascript/controllers/attachment_upload_controller.js
Normal 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
Reference in New Issue
Block a user