From 793b31a15ccc538a4d07e46361710edd25f97e0c Mon Sep 17 00:00:00 2001 From: Tin Tran Date: Tue, 5 May 2026 23:10:26 +0700 Subject: [PATCH 1/2] Carry fork SQLite runtime platform --- .devcontainer/Dockerfile | 1 - .devcontainer/docker-compose.yml | 50 +- .env.example | 16 - .github/workflows/ci.yml | 55 +- .github/workflows/publish.yml | 2 +- .ruby-version | 2 +- Dockerfile | 16 +- Gemfile | 41 +- Gemfile.lock | 702 ++++---- Procfile.dev | 3 +- bin/ci | 6 + bin/docker-entrypoint | 1 + bin/jobs | 6 + bin/rubocop | 2 +- bin/setup | 10 +- compose.example.yml | 83 +- config/application.rb | 11 +- config/cable.yml | 19 +- config/cache.yml | 17 + config/ci.rb | 22 + config/database.yml | 54 +- config/environments/development.rb | 58 +- config/environments/production.rb | 107 +- config/environments/test.rb | 13 +- config/initializers/assets.rb | 1 + .../initializers/content_security_policy.rb | 4 + .../initializers/filter_parameter_logging.rb | 3 +- config/initializers/generator.rb | 2 +- config/initializers/sidekiq.rb | 78 - config/puma.rb | 20 +- config/queue.yml | 18 + config/recurring.yml | 35 + config/schedule.yml | 44 - config/sidekiq.yml | 7 - db/cable_schema.rb | 23 + db/cache_schema.rb | 24 + .../20250924124734_change_uuid_to_string.rb | 328 ++++ .../20250924142630_change_json_b_to_json.rb | 54 + ...counts_classification_to_regular_column.rb | 19 + ...nces_virtual_columns_to_regular_columns.rb | 25 + ...5_remove_functional_and_partial_indexes.rb | 25 + ...5_convert_billed_products_array_to_json.rb | 30 + ...0250927190818_remove_uuid_default_value.rb | 88 + ..._uuid_defaults_to_active_storage_tables.rb | 8 + ...25_add_uuid_default_to_categories_table.rb | 6 + ...9215928_add_uuid_defaults_to_all_tables.rb | 77 + ...0250930191642_change_default_id_to_ulid.rb | 17 + ..._to_active_storage_blobs.active_storage.rb | 22 + ..._storage_variant_records.active_storage.rb | 27 + ...e_storage_blobs_checksum.active_storage.rb | 8 + ...8_update_id_defaults_to_gen_random_uuid.rb | 27 + db/queue_schema.rb | 141 ++ db/schema.rb | 1540 +++++------------ db/seeds.rb | 8 +- mise.toml | 3 + 55 files changed, 2014 insertions(+), 1995 deletions(-) create mode 100755 bin/ci create mode 100755 bin/jobs create mode 100644 config/cache.yml create mode 100644 config/ci.rb delete mode 100644 config/initializers/sidekiq.rb create mode 100644 config/queue.yml create mode 100644 config/recurring.yml delete mode 100644 config/schedule.yml delete mode 100644 config/sidekiq.yml create mode 100644 db/cable_schema.rb create mode 100644 db/cache_schema.rb create mode 100644 db/migrate/20250924124734_change_uuid_to_string.rb create mode 100644 db/migrate/20250924142630_change_json_b_to_json.rb create mode 100644 db/migrate/20250924190901_convert_accounts_classification_to_regular_column.rb create mode 100644 db/migrate/20250924191112_convert_account_balances_virtual_columns_to_regular_columns.rb create mode 100644 db/migrate/20250924194505_remove_functional_and_partial_indexes.rb create mode 100644 db/migrate/20250924215505_convert_billed_products_array_to_json.rb create mode 100644 db/migrate/20250927190818_remove_uuid_default_value.rb create mode 100644 db/migrate/20250928213010_add_uuid_defaults_to_active_storage_tables.rb create mode 100644 db/migrate/20250928220425_add_uuid_default_to_categories_table.rb create mode 100644 db/migrate/20250929215928_add_uuid_defaults_to_all_tables.rb create mode 100644 db/migrate/20250930191642_change_default_id_to_ulid.rb create mode 100644 db/migrate/20251003011536_add_service_name_to_active_storage_blobs.active_storage.rb create mode 100644 db/migrate/20251003011537_create_active_storage_variant_records.active_storage.rb create mode 100644 db/migrate/20251003011538_remove_not_null_on_active_storage_blobs_checksum.active_storage.rb create mode 100644 db/migrate/20251008015408_update_id_defaults_to_gen_random_uuid.rb create mode 100644 db/queue_schema.rb create mode 100644 mise.toml diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index b871287d42e..e21a8732011 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -15,7 +15,6 @@ RUN apt-get update -qq \ libyaml-dev \ libyaml-0-2 \ openssh-client \ - postgresql-client \ vim \ procps \ && rm -rf /var/lib/apt/lists /var/cache/apt/archives diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index d1825c041aa..136407af806 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -1,18 +1,6 @@ -x-db-env: &db_env - POSTGRES_USER: postgres - POSTGRES_DB: postgres - POSTGRES_PASSWORD: postgres - x-rails-env: &rails_env - DB_HOST: db - # Bind the dev server to all interfaces inside the container so Docker's - # published port reaches it from the host. Rails reads BINDING natively. - BINDING: "0.0.0.0" - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres + HOST: "0.0.0.0" BUNDLE_PATH: /bundle - REDIS_URL: redis://redis:6379/1 - SELENIUM_REMOTE_URL: http://selenium:4444 services: app: @@ -28,40 +16,6 @@ services: command: sleep infinity environment: <<: *rails_env - depends_on: - - db - - redis - - selenium - - worker: - build: - context: .. - dockerfile: .devcontainer/Dockerfile - volumes: - - ..:/workspace:cached - - bundle_cache:/bundle - command: bundle exec sidekiq - restart: unless-stopped - environment: - <<: *rails_env - depends_on: - - redis - - redis: - image: redis:latest - volumes: - - redis-data:/data - restart: unless-stopped - - db: - image: postgres:16 - volumes: - - postgres-data:/var/lib/postgresql/data - ports: - - "5432:5432" - restart: unless-stopped - environment: - <<: *db_env selenium: image: selenium/standalone-chromium:latest @@ -72,6 +26,4 @@ services: restart: unless-stopped volumes: - postgres-data: - redis-data: bundle_cache: diff --git a/.env.example b/.env.example index e0bc454b1db..cc0df22880f 100644 --- a/.env.example +++ b/.env.example @@ -95,22 +95,6 @@ SMTP_TLS_SKIP_VERIFY=false EMAIL_SENDER= # Database Configuration -DB_HOST=localhost # May need to be changed to `DB_HOST=db` if using devcontainer -DB_PORT=5432 -POSTGRES_PASSWORD=postgres # pipelock:ignore -POSTGRES_USER=postgres - -# Redis configuration -# Standard Redis URL (for direct connection) -REDIS_URL=redis://localhost:6379/1 - -# Redis Sentinel configuration (for high availability) -# When REDIS_SENTINEL_HOSTS is set, it takes precedence over REDIS_URL -# REDIS_SENTINEL_HOSTS=sentinel1:26379,sentinel2:26379,sentinel3:26379 -# REDIS_SENTINEL_MASTER=mymaster -# REDIS_SENTINEL_USERNAME=default -# REDIS_PASSWORD=your-redis-password # pipelock:ignore - # App Domain # This is the domain that your Sure instance will be hosted at. It is used to generate links in emails and other places. APP_DOMAIN= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eae6bb5e342..4484f0357a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,7 @@ name: CI on: workflow_call: + workflow_dispatch: jobs: scan_ruby: @@ -9,7 +10,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Ruby uses: ruby/setup-ruby@v1 @@ -25,7 +26,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Ruby uses: ruby/setup-ruby@v1 @@ -41,7 +42,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Ruby uses: ruby/setup-ruby@v1 @@ -57,10 +58,10 @@ jobs: timeout-minutes: 10 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node.js environment - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: "20" cache: "npm" @@ -79,32 +80,14 @@ jobs: env: PLAID_CLIENT_ID: foo PLAID_SECRET: bar - DATABASE_URL: postgres://postgres:postgres@localhost:5432 - REDIS_URL: redis://localhost:6379 RAILS_ENV: test - services: - postgres: - image: postgres - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - ports: - - 5432:5432 - options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3 - - redis: - image: redis - ports: - - 6379:6379 - options: --health-cmd="redis-cli ping" --health-interval=10s --health-timeout=5s --health-retries=3 - steps: - name: Install packages - run: sudo apt-get update && sudo apt-get install --no-install-recommends -y google-chrome-stable curl libvips postgresql-client libpq-dev + run: sudo apt-get update && sudo apt-get install --no-install-recommends -y curl libvips - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Ruby uses: ruby/setup-ruby@v1 @@ -121,8 +104,28 @@ jobs: - name: Unit and integration tests run: bin/rails test + system-test: + runs-on: ubuntu-latest + timeout-minutes: 10 + + env: + RAILS_ENV: test + + steps: + - name: Install packages + run: sudo apt-get update && sudo apt-get install --no-install-recommends -y google-chrome-stable curl libvips + + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + - name: System tests - run: DISABLE_PARALLELIZATION=true bin/rails test:system + run: bin/rails test:system - name: Keep screenshots from failed system tests uses: actions/upload-artifact@v4 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index afdfd35a76a..4972131ddb5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -73,7 +73,7 @@ jobs: steps: - name: Check out the repo - uses: actions/checkout@v4.2.0 + uses: actions/checkout@v6 with: ref: ${{ github.event.inputs.ref || github.ref }} diff --git a/.ruby-version b/.ruby-version index 2aa51319921..4b4ea1b6883 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.7 +3.4.6 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 2cb2c83e796..a58ed16754b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,31 +1,30 @@ # syntax = docker/dockerfile:1 # Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile -ARG RUBY_VERSION=3.4.7 +ARG RUBY_VERSION=3.4.6 FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base # Rails app lives here WORKDIR /rails # Install base packages +# procps package is needed for litestream because it uses the ps command internally RUN apt-get update -qq \ - && apt-get install --no-install-recommends -y curl libvips postgresql-client libyaml-0-2 procps \ + && apt-get install --no-install-recommends -y curl libvips libyaml-0-2 procps \ && rm -rf /var/lib/apt/lists /var/cache/apt/archives # Set production environment -ARG BUILD_COMMIT_SHA ENV RAILS_ENV="production" \ BUNDLE_DEPLOYMENT="1" \ BUNDLE_PATH="/usr/local/bundle" \ - BUNDLE_WITHOUT="development" \ - BUILD_COMMIT_SHA=${BUILD_COMMIT_SHA} - + BUNDLE_WITHOUT="development" + # Throw-away build stage to reduce size of final image FROM base AS build # Install packages needed to build gems RUN apt-get update -qq \ - && apt-get install --no-install-recommends -y build-essential libpq-dev git pkg-config libyaml-dev \ + && apt-get install --no-install-recommends -y build-essential git pkg-config libyaml-dev \ && rm -rf /var/lib/apt/lists /var/cache/apt/archives # Install application gems @@ -55,6 +54,9 @@ USER 1000:1000 COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" COPY --chown=rails:rails --from=build /rails /rails +ARG BUILD_COMMIT_SHA +ENV BUILD_COMMIT_SHA=${BUILD_COMMIT_SHA} + # Entrypoint prepares the database. ENTRYPOINT ["/rails/bin/docker-entrypoint"] diff --git a/Gemfile b/Gemfile index a7978ceee9e..59f2b0ca176 100644 --- a/Gemfile +++ b/Gemfile @@ -3,11 +3,7 @@ source "https://rubygems.org" ruby file: ".ruby-version" # Rails -gem "rails", "~> 7.2.2" - -# Drivers -gem "pg", "~> 1.5" -gem "redis", "~> 5.4" +gem "rails", "~> 8.1.1" # Deployment gem "puma", ">= 5.0" @@ -27,18 +23,12 @@ gem "view_component" 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" # Monitoring gem "vernier" gem "rack-mini-profiler" gem "sentry-ruby" gem "sentry-rails" -gem "sentry-sidekiq" -gem "posthog-ruby" gem "logtail-rails" gem "skylight", groups: [ :production ] @@ -57,6 +47,7 @@ gem "countries" # OAuth & API Security gem "doorkeeper" +gem "rswag-ui" gem "rack-attack", "~> 6.6" gem "rack-cors" gem "pundit" @@ -65,10 +56,9 @@ gem "faraday-retry" gem "faraday-multipart" gem "inline_svg" gem "octokit" -gem "pagy" -gem "rails-i18n" +gem "pagy", "~> 9.0" gem "rails-settings-cached" -gem "tzinfo-data", platforms: %i[windows jruby] +gem "tzinfo-data", platforms: %i[mingw mswin x64_mingw jruby] gem "csv" gem "rchardet" # Character encoding detection gem "redcarpet" @@ -100,12 +90,13 @@ gem "ruby-openai" gem "langfuse-ruby", "~> 0.1.4", require: "langfuse" group :development, :test do - gem "debug", platforms: %i[mri windows] + gem "debug", platforms: %i[mri mingw mswin x64_mingw] gem "brakeman", require: false gem "rubocop-rails-omakase", require: false gem "i18n-tasks" gem "erb_lint" gem "dotenv-rails" + gem "rswag-specs" end if ENV["BENCHMARKING_ENABLED"] @@ -143,3 +134,23 @@ group :test do gem "climate_control" gem "simplecov", require: false end + +gem "rails_db", "~> 2.5" + +gem "seed_dump", github: "rroblak/seed_dump" + +gem "solid_cache", "~> 1.0" + +gem "sqlite3", "~> 2.7" + +gem "solid_queue", "~> 1.2" + +gem "solid_cable", "~> 3.0" + +gem "mission_control-jobs", "~> 1.1" + +gem "sqlean", "~> 0.3.0" + +gem "litestream", "~> 0.14.0" + +gem "honeybadger", "~> 6.1" diff --git a/Gemfile.lock b/Gemfile.lock index 2b3ab3c5f3d..c24bfe9aab7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,84 +1,100 @@ +GIT + remote: https://github.com/maybe-finance/lucide-rails.git + revision: 272e5fb8418ea458da3995d6abe0ba0ceee9c9f0 + specs: + lucide-rails (0.2.0) + railties (>= 4.1.0) + +GIT + remote: https://github.com/rroblak/seed_dump.git + revision: c2fa31924c1f725450619b30a40e94f6b2d632b4 + specs: + seed_dump (3.3.1) + activerecord (>= 4) + activesupport (>= 4) + GEM remote: https://rubygems.org/ specs: - Ascii85 (2.0.1) - aasm (5.5.1) + aasm (5.5.2) concurrent-ruby (~> 1.0) - actioncable (7.2.3.1) - actionpack (= 7.2.3.1) - activesupport (= 7.2.3.1) + action_text-trix (2.1.15) + railties + actioncable (8.1.1) + actionpack (= 8.1.1) + activesupport (= 8.1.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - 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) + actionmailbox (8.1.1) + actionpack (= 8.1.1) + activejob (= 8.1.1) + activerecord (= 8.1.1) + activestorage (= 8.1.1) + activesupport (= 8.1.1) mail (>= 2.8.0) - 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) + actionmailer (8.1.1) + actionpack (= 8.1.1) + actionview (= 8.1.1) + activejob (= 8.1.1) + activesupport (= 8.1.1) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (7.2.3.1) - actionview (= 7.2.3.1) - activesupport (= 7.2.3.1) - cgi + actionpack (8.1.1) + actionview (= 8.1.1) + activesupport (= 8.1.1) nokogiri (>= 1.8.5) - racc - rack (>= 2.2.4, < 3.3) + rack (>= 2.2.4) 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.3.1) - actionpack (= 7.2.3.1) - activerecord (= 7.2.3.1) - activestorage (= 7.2.3.1) - activesupport (= 7.2.3.1) + actiontext (8.1.1) + action_text-trix (~> 2.1.15) + actionpack (= 8.1.1) + activerecord (= 8.1.1) + activestorage (= 8.1.1) + activesupport (= 8.1.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.2.3.1) - activesupport (= 7.2.3.1) + actionview (8.1.1) + activesupport (= 8.1.1) builder (~> 3.1) cgi erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (7.2.3.1) - activesupport (= 7.2.3.1) + activejob (8.1.1) + activesupport (= 8.1.1) globalid (>= 0.3.6) - 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) + activemodel (8.1.1) + activesupport (= 8.1.1) + activerecord (8.1.1) + activemodel (= 8.1.1) + activesupport (= 8.1.1) timeout (>= 0.4.0) activerecord-import (2.2.0) activerecord (>= 4.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) + activestorage (8.1.1) + actionpack (= 8.1.1) + activejob (= 8.1.1) + activerecord (= 8.1.1) + activesupport (= 8.1.1) marcel (~> 1.0) - activesupport (7.2.3.1) + activesupport (8.1.1) base64 - benchmark (>= 0.3) bigdecimal concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) + json logger (>= 1.4.2) minitest (>= 5.1, < 6) securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) aes_key_wrap (1.1.0) @@ -90,8 +106,8 @@ GEM ast (2.4.3) attr_required (1.0.2) aws-eventstream (1.4.0) - aws-partitions (1.1196.0) - aws-sdk-core (3.240.0) + aws-partitions (1.1180.0) + aws-sdk-core (3.236.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -99,8 +115,8 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.118.0) - aws-sdk-core (~> 3, >= 3.239.1) + aws-sdk-kms (1.116.0) + aws-sdk-core (~> 3, >= 3.234.0) aws-sigv4 (~> 1.5) aws-sdk-s3 (1.208.0) aws-sdk-core (~> 3, >= 3.234.0) @@ -109,22 +125,20 @@ GEM aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) base64 (0.3.0) - bcrypt (3.1.22) - benchmark (0.5.0) + bcrypt (3.1.20) benchmark-ips (2.14.0) - better_html (2.1.1) - actionview (>= 6.0) - activesupport (>= 6.0) + better_html (2.2.0) + actionview (>= 7.0) + activesupport (>= 7.0) ast (~> 2.0) erubi (~> 1.4) parser (>= 2.4) smart_properties bigdecimal (3.3.1) - bindata (2.5.1) bindex (0.8.1) bootsnap (1.18.6) msgpack (~> 1.2) - brakeman (7.1.2) + brakeman (8.0.4) racc builder (3.3.0) capybara (3.40.0) @@ -142,24 +156,18 @@ GEM logger (~> 1.5) chunky_png (1.4.0) climate_control (1.2.0) - concurrent-ruby (1.3.6) - connection_pool (2.5.5) - cose (1.3.1) - cbor (~> 0.5.9) - openssl-signature_algorithm (~> 1.0) - countries (8.0.3) + concurrent-ruby (1.3.5) + connection_pool (2.5.4) + countries (8.0.4) unaccent (~> 0.3) - crack (1.0.0) + crack (1.0.1) bigdecimal rexml crass (1.0.6) - cronex (0.15.0) - tzinfo - unicode (>= 0.4.4.5) css_parser (1.21.1) addressable csv (3.3.5) - date (3.4.1) + date (3.5.0) debug (1.11.0) irb (~> 1.10) reline (>= 0.3.8) @@ -193,10 +201,7 @@ GEM dotenv (= 3.1.8) railties (>= 6.1) drb (2.2.3) - ed25519 (1.4.0) - email_validator (2.2.4) - activemodel - erb (5.0.1) + erb (6.0.0) erb_lint (0.9.0) activesupport better_html (>= 2.0.1) @@ -205,12 +210,12 @@ GEM rubocop (>= 1) smart_properties erubi (1.13.1) - et-orbi (1.2.11) + et-orbi (1.4.0) tzinfo event_stream_parser (1.0.0) faker (3.5.2) i18n (>= 1.8.11, < 2) - faraday (2.14.1) + faraday (2.14.0) faraday-net_http (>= 2.0, < 3.5) json logger @@ -223,67 +228,28 @@ GEM faraday-retry (2.3.2) faraday (~> 2.0) ffi (1.17.2-aarch64-linux-gnu) - ffi (1.17.2-aarch64-linux-musl) - ffi (1.17.2-arm-linux-gnu) - ffi (1.17.2-arm-linux-musl) ffi (1.17.2-arm64-darwin) ffi (1.17.2-x86_64-darwin) ffi (1.17.2-x86_64-linux-gnu) - ffi (1.17.2-x86_64-linux-musl) - foreman (0.88.1) - fugit (1.11.1) - et-orbi (~> 1, >= 1.2.11) + foreman (0.90.0) + thor (~> 1.4) + fugit (1.12.1) + et-orbi (~> 1.4) raabro (~> 1.4) get_process_mem (1.0.0) bigdecimal (>= 2.0) ffi (~> 1.0) globalid (1.3.0) activesupport (>= 6.1) - google-apis-core (1.0.2) - addressable (~> 2.8, >= 2.8.7) - faraday (~> 2.13) - faraday-follow_redirects (~> 0.3) - googleauth (~> 1.14) - mini_mime (~> 1.1) - representable (~> 3.0) - retriable (~> 3.1) - google-apis-iamcredentials_v1 (0.26.0) - google-apis-core (>= 0.15.0, < 2.a) - google-apis-storage_v1 (0.61.0) - google-apis-core (>= 0.15.0, < 2.a) - google-cloud-core (1.8.0) - google-cloud-env (>= 1.0, < 3.a) - google-cloud-errors (~> 1.0) - google-cloud-env (2.3.1) - base64 (~> 0.2) - faraday (>= 1.0, < 3.a) - google-cloud-errors (1.6.0) - google-cloud-storage (1.59.0) - addressable (~> 2.8) - digest-crc (~> 0.4) - google-apis-core (>= 0.18, < 2) - google-apis-iamcredentials_v1 (~> 0.18) - google-apis-storage_v1 (>= 0.42) - google-cloud-core (~> 1.6) - googleauth (~> 1.9) - mini_mime (~> 1.0) - google-logging-utils (0.2.0) - googleauth (1.16.2) - faraday (>= 1.0, < 3.a) - google-cloud-env (~> 2.2) - google-logging-utils (~> 0.1) - jwt (>= 1.4, < 4.0) - multi_json (~> 1.11) - os (>= 0.9, < 2.0) - signet (>= 0.16, < 2.a) - hashdiff (1.2.0) - hashery (2.1.2) - hashie (5.0.0) + hashdiff (1.2.1) heapy (0.2.0) thor highline (3.1.2) reline - hotwire-livereload (2.0.0) + honeybadger (6.1.3) + logger + ostruct + hotwire-livereload (2.1.1) actioncable (>= 7.0.0) listen (>= 3.0.0) railties (>= 7.0.0) @@ -294,7 +260,7 @@ GEM turbo-rails (>= 1.2) htmlbeautifier (1.4.3) htmlentities (4.3.4) - httparty (0.24.0) + httparty (0.23.2) csv mini_mime (>= 1.0.0) multi_xml (>= 0.5.2) @@ -314,35 +280,40 @@ GEM image_processing (1.14.0) mini_magick (>= 4.9.5, < 6) ruby-vips (>= 2.0.17, < 3) - importmap-rails (2.1.0) + importmap-rails (2.2.2) actionpack (>= 6.0.0) activesupport (>= 6.0.0) railties (>= 6.0.0) inline_svg (1.10.0) activesupport (>= 3.0) nokogiri (>= 1.6) - io-console (0.8.0) - irb (1.15.2) + io-console (0.8.1) + irb (1.15.3) pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) - jbuilder (2.13.0) - actionview (>= 5.0.0) - activesupport (>= 5.0.0) + jbuilder (2.14.1) + actionview (>= 7.0.0) + activesupport (>= 7.0.0) jmespath (1.6.2) - json (2.19.2) - json-jwt (1.16.7) - activesupport (>= 4.2) - aes_key_wrap - base64 - bindata - faraday (~> 2.0) - faraday-follow_redirects - json-schema (5.2.2) + json (2.15.2) + json-schema (6.2.0) addressable (~> 2.8) - bigdecimal (~> 3.1) - jwt (2.10.2) + bigdecimal (>= 3.1, < 5) + jwt (3.1.2) base64 + kaminari (1.2.2) + activesupport (>= 4.1.0) + kaminari-actionview (= 1.2.2) + kaminari-activerecord (= 1.2.2) + kaminari-core (= 1.2.2) + kaminari-actionview (1.2.2) + actionview + kaminari-core (= 1.2.2) + kaminari-activerecord (1.2.2) + activerecord + kaminari-core (= 1.2.2) + kaminari-core (1.2.2) langfuse-ruby (0.1.4) concurrent-ruby (~> 1.0) faraday (>= 1.8, < 3.0) @@ -359,13 +330,41 @@ GEM listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) + litestream (0.14.0-aarch64-linux) + actionpack (>= 7.0) + actionview (>= 7.0) + activejob (>= 7.0) + activesupport (>= 7.0) + railties (>= 7.0) + sqlite3 + litestream (0.14.0-arm64-darwin) + actionpack (>= 7.0) + actionview (>= 7.0) + activejob (>= 7.0) + activesupport (>= 7.0) + railties (>= 7.0) + sqlite3 + litestream (0.14.0-x86_64-darwin) + actionpack (>= 7.0) + actionview (>= 7.0) + activejob (>= 7.0) + activesupport (>= 7.0) + railties (>= 7.0) + sqlite3 + litestream (0.14.0-x86_64-linux) + actionpack (>= 7.0) + actionview (>= 7.0) + activejob (>= 7.0) + activesupport (>= 7.0) + railties (>= 7.0) + sqlite3 logger (1.7.0) logtail (0.1.17) msgpack (~> 1.0) logtail-rack (0.2.6) logtail (~> 0.1) rack (>= 1.2, < 4.0) - logtail-rails (0.2.10) + logtail-rails (0.2.11) actionpack (>= 5.0.0) activerecord (>= 5.0.0) logtail (~> 0.1, >= 0.1.14) @@ -386,23 +385,30 @@ GEM view_component (>= 2.0) yard (~> 0.9) zeitwerk (~> 2.5) - lucide-rails (0.7.3) - railties (>= 4.1.0) - mail (2.8.1) + mail (2.9.0) + logger mini_mime (>= 0.1.1) net-imap net-pop net-smtp marcel (1.1.0) - matrix (0.4.2) + matrix (0.4.3) memory_profiler (1.1.0) - method_source (1.1.0) mini_histogram (0.3.1) - mini_magick (5.2.0) - benchmark + mini_magick (5.3.1) logger mini_mime (1.1.5) - minitest (5.27.0) + minitest (5.26.1) + mission_control-jobs (1.1.0) + actioncable (>= 7.1) + actionpack (>= 7.1) + activejob (>= 7.1) + activerecord (>= 7.1) + importmap-rails (>= 1.2.1) + irb (~> 1.13) + railties (>= 7.1) + stimulus-rails + turbo-rails mocha (2.7.1) ruby2_keywords (>= 0.0.5) msgpack (1.8.0) @@ -411,9 +417,9 @@ GEM bigdecimal (>= 3.1, < 5) multipart-post (2.4.1) mutex_m (0.3.0) - net-http (0.9.1) - uri (>= 0.11.1) - net-imap (0.5.8) + net-http (0.7.0) + uri + net-imap (0.5.12) date net-protocol net-pop (0.1.2) @@ -422,22 +428,14 @@ GEM timeout net-smtp (0.5.1) net-protocol - nio4r (2.7.4) - nokogiri (1.19.2-aarch64-linux-gnu) - racc (~> 1.4) - nokogiri (1.19.2-aarch64-linux-musl) - racc (~> 1.4) - nokogiri (1.19.2-arm-linux-gnu) - racc (~> 1.4) - nokogiri (1.19.2-arm-linux-musl) + nio4r (2.7.5) + nokogiri (1.18.10-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.2-arm64-darwin) + nokogiri (1.18.10-arm64-darwin) racc (~> 1.4) - nokogiri (1.19.2-x86_64-darwin) + nokogiri (1.18.10-x86_64-darwin) racc (~> 1.4) - nokogiri (1.19.2-x86_64-linux-gnu) - racc (~> 1.4) - nokogiri (1.19.2-x86_64-linux-musl) + nokogiri (1.18.10-x86_64-linux-gnu) racc (~> 1.4) oauth2 (2.0.18) faraday (>= 0.17.3, < 4.0) @@ -450,94 +448,40 @@ GEM octokit (10.0.0) faraday (>= 1, < 3) sawyer (~> 0.9) - omniauth (2.1.3) - hashie (>= 3.4.6) - rack (>= 2.2.3) - rack-protection - omniauth-github (2.0.1) - omniauth (~> 2.0) - omniauth-oauth2 (~> 1.8) - omniauth-google-oauth2 (1.2.1) - jwt (>= 2.9.2) - oauth2 (~> 2.0) - omniauth (~> 2.0) - omniauth-oauth2 (~> 1.8) - omniauth-oauth2 (1.8.0) - oauth2 (>= 1.4, < 3) - omniauth (~> 2.0) - omniauth-rails_csrf_protection (1.0.2) - actionpack (>= 4.2) - omniauth (~> 2.0) - omniauth-saml (2.2.4) - omniauth (~> 2.1) - ruby-saml (~> 1.18) - omniauth_openid_connect (0.8.0) - omniauth (>= 1.9, < 3) - openid_connect (~> 2.2) - openid_connect (2.3.1) - activemodel - attr_required (>= 1.0.0) - email_validator - faraday (~> 2.0) - faraday-follow_redirects - json-jwt (>= 1.16) - mail - rack-oauth2 (~> 2.2) - swd (~> 2.0) - tzinfo - validate_url - webfinger (~> 2.0) - openssl (4.0.1) - openssl-signature_algorithm (1.3.0) - openssl (> 2.0) - os (1.1.4) - ostruct (0.6.2) - pagy (9.3.5) + ostruct (0.6.3) + pagy (9.4.0) parallel (1.27.0) - parser (3.3.10.2) + parser (3.3.10.0) ast (~> 2.4.1) racc - pdf-reader (2.15.1) - Ascii85 (>= 1.0, < 3.0, != 2.0.0) - afm (>= 0.2.1, < 2) - hashery (~> 2.0) - ruby-rc4 - ttfunk - pg (1.5.9) - plaid (41.0.0) + plaid (44.1.0) faraday (>= 1.0.1, < 3.0) faraday-multipart (>= 1.0.1, < 2.0) platform_agent (1.0.1) activesupport (>= 5.2.0) useragent (~> 0.16.3) - posthog-ruby (3.3.3) - concurrent-ruby (~> 1) - pp (0.6.2) + pp (0.6.3) prettyprint prettyprint (0.2.0) - prism (1.4.0) - propshaft (1.1.0) + prism (1.6.0) + propshaft (1.3.1) actionpack (>= 7.0.0) activesupport (>= 7.0.0) rack - railties (>= 7.0.0) psych (5.2.6) date stringio public_suffix (6.0.2) - puma (6.6.0) + puma (7.1.0) nio4r (~> 2.0) pundit (2.5.2) activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (3.2.6) - rack-attack (6.7.0) + rack (3.2.4) + rack-attack (6.8.0) rack (>= 1.0, < 4) - rack-cors (3.0.0) - logger - rack (>= 3.0.14) - rack-mini-profiler (4.0.0) + rack-mini-profiler (4.0.1) rack (>= 1.2.0) rack-oauth2 (2.2.1) activesupport @@ -557,20 +501,20 @@ GEM rack (>= 1.3) rackup (2.2.1) rack (>= 3) - 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) + rails (8.1.1) + actioncable (= 8.1.1) + actionmailbox (= 8.1.1) + actionmailer (= 8.1.1) + actionpack (= 8.1.1) + actiontext (= 8.1.1) + actionview (= 8.1.1) + activejob (= 8.1.1) + activemodel (= 8.1.1) + activerecord (= 8.1.1) + activestorage (= 8.1.1) + activesupport (= 8.1.1) bundler (>= 1.15.0) - railties (= 7.2.3.1) + railties (= 8.1.1) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest @@ -578,16 +522,23 @@ GEM 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) + rails-i18n (8.0.2) i18n (>= 0.7, < 2) - railties (>= 6.0.0, < 8) + railties (>= 8.0.0, < 9) rails-settings-cached (2.9.6) activerecord (>= 5.0.0) railties (>= 5.0.0) - railties (7.2.3.1) - actionpack (= 7.2.3.1) - activesupport (= 7.2.3.1) - cgi + rails_db (2.5.0) + activerecord + csv + kaminari + rails (>= 5.0.0) + ransack (>= 2.3.2) + simple_form (>= 5.0.1) + terminal-table + railties (8.1.1) + actionpack (= 8.1.1) + activesupport (= 8.1.1) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) @@ -595,65 +546,43 @@ GEM tsort (>= 0.2) zeitwerk (~> 2.6) rainbow (3.1.1) - rake (13.3.0) + rake (13.3.1) + ransack (4.4.1) + activerecord (>= 7.2) + activesupport (>= 7.2) + i18n rb-fsevent (0.11.2) rb-inotify (0.11.1) ffi (~> 1.0) - rbs (3.9.4) + rbs (3.9.5) logger - rchardet (1.10.0) - rdoc (6.14.2) + rdoc (6.15.1) erb psych (>= 4.0.0) + tsort redcarpet (3.6.1) - redis (5.4.0) - redis-client (>= 0.22.0) - redis-client (0.25.0) - connection_pool - regexp_parser (2.10.0) - reline (0.6.1) + regexp_parser (2.11.3) + reline (0.6.3) io-console (~> 0.5) - representable (3.2.0) - declarative (< 0.1.0) - trailblazer-option (>= 0.1.1, < 0.2.0) - uber (< 0.2.0) - retriable (3.4.1) - rexml (3.4.2) + rexml (3.4.4) rotp (6.3.0) - rouge (4.5.2) + rouge (4.6.1) rqrcode (3.1.0) chunky_png (~> 1.0) rqrcode_core (~> 2.0) rqrcode_core (2.0.0) rspec-core (3.13.6) rspec-support (~> 3.13.0) - rspec-expectations (3.13.5) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.13.0) - rspec-mocks (3.13.6) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.13.0) - rspec-rails (8.0.2) - actionpack (>= 7.2) - activesupport (>= 7.2) - railties (>= 7.2) - rspec-core (~> 3.13) - rspec-expectations (~> 3.13) - rspec-mocks (~> 3.13) - rspec-support (~> 3.13) - rspec-support (3.13.6) - rswag-api (2.16.0) - activesupport (>= 5.2, < 8.1) - railties (>= 5.2, < 8.1) - rswag-specs (2.16.0) - activesupport (>= 5.2, < 8.1) - json-schema (>= 2.2, < 6.0) - railties (>= 5.2, < 8.1) + rspec-support (3.13.7) + rswag-specs (2.17.0) + activesupport (>= 5.2, < 8.2) + json-schema (>= 2.2, < 7.0) + railties (>= 5.2, < 8.2) rspec-core (>= 2.14) - rswag-ui (2.16.0) - actionpack (>= 5.2, < 8.1) - railties (>= 5.2, < 8.1) - rubocop (1.76.1) + rswag-ui (2.17.0) + actionpack (>= 5.2, < 8.2) + railties (>= 5.2, < 8.2) + rubocop (1.81.7) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -661,17 +590,17 @@ GEM parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.45.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.45.1) + rubocop-ast (1.47.1) parser (>= 3.3.7.2) prism (~> 1.4) - rubocop-performance (1.25.0) + rubocop-performance (1.26.1) lint_roller (~> 1.1) rubocop (>= 1.75.0, < 2.0) - rubocop-ast (>= 1.38.0, < 2.0) - rubocop-rails (2.32.0) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rails (2.33.4) activesupport (>= 4.2.0) lint_roller (~> 1.1) rack (>= 1.1) @@ -681,13 +610,13 @@ GEM rubocop (>= 1.72) rubocop-performance (>= 1.24) rubocop-rails (>= 2.30) - ruby-lsp (0.26.9) + ruby-lsp (0.26.2) language_server-protocol (~> 3.17.0) prism (>= 1.2, < 2.0) rbs (>= 3, < 5) ruby-lsp-rails (0.4.8) ruby-lsp (>= 0.26.0, < 0.27.0) - ruby-openai (8.1.0) + ruby-openai (8.3.0) event_stream_parser (>= 0.3.0, < 2.0.0) faraday (>= 1) faraday-multipart (>= 1) @@ -697,110 +626,90 @@ GEM nokogiri (>= 1.13.10) rexml ruby-statistics (4.1.0) - ruby-vips (2.2.4) + ruby-vips (2.2.5) ffi (~> 1.12) logger ruby2_keywords (0.0.5) rubyzip (2.4.1) - safety_net_attestation (0.5.0) - jwt (>= 2.0, < 4.0) - sawyer (0.9.2) + sawyer (0.9.3) addressable (>= 2.3.5) faraday (>= 0.17.3, < 3) securerandom (0.4.1) - selenium-webdriver (4.34.0) + selenium-webdriver (4.38.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) - rubyzip (>= 1.2.2, < 3.0) + rubyzip (>= 1.2.2, < 4.0) websocket (~> 1.0) - sentry-rails (5.26.0) - railties (>= 5.0) - sentry-ruby (~> 5.26.0) - sentry-ruby (5.26.0) + sentry-rails (6.1.0) + railties (>= 5.2.0) + sentry-ruby (~> 6.1.0) + sentry-ruby (6.1.0) bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) - sentry-sidekiq (5.26.0) - sentry-ruby (~> 5.26.0) - sidekiq (>= 3.0) - sidekiq (8.0.5) - connection_pool (>= 2.5.0) - json (>= 2.9.0) - logger (>= 1.6.2) - rack (>= 3.1.0) - redis-client (>= 0.23.2) - sidekiq-cron (2.3.0) - cronex (>= 0.13.0) - fugit (~> 1.8, >= 1.11.1) - globalid (>= 1.0.1) - sidekiq (>= 6.5.0) - sidekiq-unique-jobs (8.0.11) - concurrent-ruby (~> 1.0, >= 1.0.5) - sidekiq (>= 7.0.0, < 9.0.0) - thor (>= 1.0, < 3.0) - signet (0.21.0) - addressable (~> 2.8) - faraday (>= 0.17.5, < 3.a) - jwt (>= 1.5, < 4.0) - multi_json (~> 1.10) + simple_form (5.4.0) + actionpack (>= 7.0) + activemodel (>= 7.0) simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) simplecov_json_formatter (~> 0.1) - simplecov-html (0.13.1) + simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) - skylight (6.0.4) - activesupport (>= 5.2.0) + skylight (7.0.0) + activesupport (>= 7.1.0) smart_properties (1.17.0) - snaky_hash (2.0.3) - hashie (>= 0.1.0, < 6) - version_gem (>= 1.1.8, < 3) - snaptrade (2.0.156) - faraday (>= 1.0.1, < 3.0) - faraday-multipart (~> 1.0, >= 1.0.4) + solid_cable (3.0.12) + actioncable (>= 7.2) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_cache (1.0.8) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_queue (1.2.4) + activejob (>= 7.1) + activerecord (>= 7.1) + concurrent-ruby (>= 1.3.1) + fugit (~> 1.11) + railties (>= 7.1) + thor (>= 1.3.1) + sqlean (0.3.0-aarch64-linux-gnu) + sqlean (0.3.0-arm64-darwin) + sqlean (0.3.0-x86_64-darwin) + sqlean (0.3.0-x86_64-linux-gnu) + sqlite3 (2.8.0-aarch64-linux-gnu) + sqlite3 (2.8.0-arm64-darwin) + sqlite3 (2.8.0-x86_64-darwin) + sqlite3 (2.8.0-x86_64-linux-gnu) stackprof (0.2.27) stimulus-rails (1.3.4) railties (>= 6.0.0) - stringio (3.1.7) - stripe (15.3.0) - swd (2.0.3) - activesupport (>= 3) - attr_required (>= 0.0.5) - faraday (~> 2.0) - faraday-follow_redirects - tailwindcss-rails (4.2.3) + stringio (3.1.8) + stripe (17.2.0) + tailwindcss-rails (4.4.0) railties (>= 7.0.0) tailwindcss-ruby (~> 4.0) - tailwindcss-ruby (4.1.8) - tailwindcss-ruby (4.1.8-aarch64-linux-gnu) - tailwindcss-ruby (4.1.8-aarch64-linux-musl) - tailwindcss-ruby (4.1.8-arm64-darwin) - tailwindcss-ruby (4.1.8-x86_64-darwin) - tailwindcss-ruby (4.1.8-x86_64-linux-gnu) - tailwindcss-ruby (4.1.8-x86_64-linux-musl) + tailwindcss-ruby (4.1.16-aarch64-linux-gnu) + tailwindcss-ruby (4.1.16-arm64-darwin) + tailwindcss-ruby (4.1.16-x86_64-darwin) + tailwindcss-ruby (4.1.16-x86_64-linux-gnu) terminal-table (4.0.0) unicode-display_width (>= 1.1.1, < 4) thor (1.4.0) - timeout (0.6.1) - tpm-key_attestation (0.14.1) - bindata (~> 2.4) - openssl (> 2.0) - openssl-signature_algorithm (~> 1.0) - trailblazer-option (0.1.2) + timeout (0.4.4) tsort (0.2.0) - ttfunk (1.8.0) - bigdecimal (~> 3.1) - turbo-rails (2.0.16) + turbo-rails (2.0.20) actionpack (>= 7.1.0) railties (>= 7.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) uber (0.1.0) unaccent (0.4.0) - unicode (0.4.4.5) - unicode-display_width (3.1.4) - unicode-emoji (~> 4.0, >= 4.0.4) - unicode-emoji (4.0.4) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.1.0) uri (1.1.1) useragent (0.16.11) validate_url (1.0.15) @@ -808,30 +717,16 @@ GEM public_suffix vcr (6.3.1) base64 - vernier (1.8.0) - version_gem (1.1.9) - view_component (3.23.2) - activesupport (>= 5.2.0, < 8.1) + vernier (1.8.1) + view_component (4.1.0) + activesupport (>= 7.1.0, < 8.2) concurrent-ruby (~> 1) - method_source (~> 1.0) web-console (4.2.1) actionview (>= 6.0.0) activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) - webauthn (3.4.3) - android_key_attestation (~> 0.3.0) - bindata (~> 2.4) - cbor (~> 0.5.9) - cose (~> 1.1) - openssl (>= 2.2) - safety_net_attestation (~> 0.5.0) - tpm-key_attestation (~> 0.14.0) - webfinger (2.1.3) - activesupport - faraday (~> 2.0) - faraday-follow_redirects - webmock (3.25.1) + webmock (3.26.1) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) @@ -847,13 +742,9 @@ GEM PLATFORMS aarch64-linux-gnu - aarch64-linux-musl - arm-linux-gnu - arm-linux-musl arm64-darwin x86_64-darwin - x86_64-linux-gnu - x86_64-linux-musl + x86_64-linux DEPENDENCIES aasm @@ -880,7 +771,7 @@ DEPENDENCIES faraday-multipart faraday-retry foreman - google-cloud-storage (~> 1.59) + honeybadger (~> 6.1) hotwire-livereload hotwire_combobox httparty @@ -892,9 +783,11 @@ DEPENDENCIES jwt langfuse-ruby (~> 0.1.4) letter_opener + litestream (~> 0.14.0) logtail-rails lookbook (= 2.3.11) - lucide-rails + lucide-rails! + mission_control-jobs (~> 1.1) mocha octokit omniauth (~> 2.1) @@ -904,9 +797,7 @@ DEPENDENCIES omniauth-saml (~> 2.1) omniauth_openid_connect ostruct - pagy - pdf-reader (~> 2.12) - pg (~> 1.5) + pagy (~> 9.0) plaid posthog-ruby propshaft @@ -915,32 +806,29 @@ DEPENDENCIES rack-attack (~> 6.6) rack-cors rack-mini-profiler - rails (~> 7.2.2) - rails-i18n + rails (~> 8.1.1) rails-settings-cached - rchardet + rails_db (~> 2.5) redcarpet - redis (~> 5.4) rotp (~> 6.3) rqrcode (~> 3.0) - rspec-rails - rswag-api rswag-specs rswag-ui rubocop-rails-omakase ruby-lsp-rails ruby-openai rubyzip (~> 2.3) + seed_dump! selenium-webdriver sentry-rails sentry-ruby - sentry-sidekiq - sidekiq - sidekiq-cron - sidekiq-unique-jobs simplecov skylight - snaptrade (~> 2.0) + solid_cable (~> 3.0) + solid_cache (~> 1.0) + solid_queue (~> 1.2) + sqlean (~> 0.3.0) + sqlite3 (~> 2.7) stackprof stimulus-rails stripe @@ -955,7 +843,7 @@ DEPENDENCIES webmock RUBY VERSION - ruby 3.4.7p58 + ruby 3.4.6p54 BUNDLED WITH - 2.6.7 + 2.7.2 diff --git a/Procfile.dev b/Procfile.dev index a868e97a4c6..bbfe2ccc8ef 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,3 +1,2 @@ -web: bundle exec ${DEBUG:+rdbg -O -n -c --} bin/rails server +web: OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES bundle exec ${DEBUG:+rdbg -O -n -c --} bin/rails server -b 0.0.0.0 css: bundle exec bin/rails tailwindcss:watch 2>/dev/null -worker: bundle exec sidekiq diff --git a/bin/ci b/bin/ci new file mode 100755 index 00000000000..4137ad5bb07 --- /dev/null +++ b/bin/ci @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "active_support/continuous_integration" + +CI = ActiveSupport::ContinuousIntegration +require_relative "../config/ci.rb" diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint index 67ef493142a..9873346d6b9 100755 --- a/bin/docker-entrypoint +++ b/bin/docker-entrypoint @@ -2,6 +2,7 @@ # If running the rails server then create or migrate existing database if [ "${1}" == "./bin/rails" ] && [ "${2}" == "server" ]; then + ./bin/rails litestream:restore -- --database=storage/production_primary.sqlite3 --if-db-not-exists ./bin/rails db:prepare fi diff --git a/bin/jobs b/bin/jobs new file mode 100755 index 00000000000..dcf59f309ae --- /dev/null +++ b/bin/jobs @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby + +require_relative "../config/environment" +require "solid_queue/cli" + +SolidQueue::Cli.start(ARGV) diff --git a/bin/rubocop b/bin/rubocop index 40330c0ff1c..5a20504716c 100755 --- a/bin/rubocop +++ b/bin/rubocop @@ -2,7 +2,7 @@ require "rubygems" require "bundler/setup" -# explicit rubocop config increases performance slightly while avoiding config confusion. +# Explicit RuboCop config increases performance slightly while avoiding config confusion. ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) load Gem.bin_path("rubocop", "rubocop") diff --git a/bin/setup b/bin/setup index 83098e2485e..39ccc6245d6 100755 --- a/bin/setup +++ b/bin/setup @@ -1,7 +1,6 @@ #!/usr/bin/env ruby require "fileutils" -# path to your application root. APP_ROOT = File.expand_path("..", __dir__) def system!(*args) @@ -14,7 +13,6 @@ FileUtils.chdir APP_ROOT do # Add necessary setup steps to this file. puts "== Installing dependencies ==" - system! "gem install bundler --conservative" system("bundle check") || system!("bundle install") puts "\n== Building design tokens ==" @@ -28,10 +26,14 @@ FileUtils.chdir APP_ROOT do puts "\n== Preparing database ==" system! "bin/rails db:prepare" + system! "bin/rails db:reset" if ARGV.include?("--reset") puts "\n== Removing old logs and tempfiles ==" system! "bin/rails log:clear tmp:clear" - puts "\n== Restarting application server ==" - system! "bin/rails restart" + unless ARGV.include?("--skip-server") + puts "\n== Starting development server ==" + STDOUT.flush # flush the output before exec(2) so that it displays + exec "bin/dev" + end end diff --git a/compose.example.yml b/compose.example.yml index 108fd6b9a8b..d39b1770681 100644 --- a/compose.example.yml +++ b/compose.example.yml @@ -42,11 +42,6 @@ # Or use explicit DNS servers that prefer IPv4 (already configured below). # -x-db-env: &db_env - POSTGRES_USER: ${POSTGRES_USER:-sure_user} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-sure_password} - POSTGRES_DB: ${POSTGRES_DB:-sure_production} - x-rails-env: &rails_env <<: *db_env SECRET_KEY_BASE: ${SECRET_KEY_BASE:-a7523c3d0ae56415046ad8abae168d71074a79534a7062258f8d1d51ac2f76d3c3bc86d86b6b0b307df30d9a6a90a2066a3fa9e67c5e6f374dbd7dd4e0778e13} @@ -55,8 +50,7 @@ x-rails-env: &rails_env RAILS_ASSUME_SSL: "false" DB_HOST: db DB_PORT: 5432 - REDIS_URL: redis://redis:6379/1 - # NOTE: enabling OpenAI will incur costs when you use AI-related features in the app (chat, rules). Make sure you have set appropriate spend limits on your account before adding this. +# NOTE: enabling OpenAI will incur costs when you use AI-related features in the app (chat, rules). Make sure you have set appropriate spend limits on your account before adding this. OPENAI_ACCESS_TOKEN: ${OPENAI_ACCESS_TOKEN} services: @@ -77,86 +71,11 @@ services: depends_on: db: condition: service_healthy - redis: - condition: service_healthy - dns: - - 8.8.8.8 - - 1.1.1.1 - networks: - - sure_net - - worker: - image: ghcr.io/we-promise/sure:stable - command: bundle exec sidekiq - volumes: - - app-storage:/rails/storage - restart: unless-stopped - depends_on: - db: - condition: service_healthy - redis: - condition: service_healthy - dns: - - 8.8.8.8 - - 1.1.1.1 - environment: - <<: *rails_env - networks: - - sure_net - - db: - image: postgres:16 - restart: unless-stopped - volumes: - - postgres-data:/var/lib/postgresql/data - environment: - <<: *db_env - healthcheck: - test: [ "CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" ] - interval: 5s - timeout: 5s - retries: 5 - networks: - - sure_net - - backup: - profiles: - - backup - image: prodrigestivill/postgres-backup-local - restart: unless-stopped - volumes: - - /opt/sure-data/backups:/backups # Change this path to your desired backup location on the host machine - environment: - - POSTGRES_HOST=db - - POSTGRES_DB=${POSTGRES_DB:-sure_production} - - POSTGRES_USER=${POSTGRES_USER:-sure_user} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-sure_password} # pipelock:ignore - - SCHEDULE=@daily # Runs once a day at midnight - - BACKUP_KEEP_DAYS=7 # Keeps the last 7 days of backups - - BACKUP_KEEP_WEEKS=4 # Keeps 4 weekly backups - - BACKUP_KEEP_MONTHS=6 # Keeps 6 monthly backups - depends_on: - - db - networks: - - sure_net - - redis: - image: redis:latest - restart: unless-stopped - volumes: - - redis-data:/data - healthcheck: - test: [ "CMD", "redis-cli", "ping" ] - interval: 5s - timeout: 5s - retries: 5 networks: - sure_net volumes: app-storage: - postgres-data: - redis-data: networks: sure_net: diff --git a/config/application.rb b/config/application.rb index b1eb3ce6011..4db15eeb251 100644 --- a/config/application.rb +++ b/config/application.rb @@ -9,7 +9,7 @@ module Sure class Application < Rails::Application # Initialize configuration defaults for originally generated Rails version. - config.load_defaults 7.2 + config.load_defaults 8.1 # Please, add to the `ignore` list any other `lib` subdirectories that do # not contain `.rb` files, or that should not be reloaded or eager loaded. @@ -47,11 +47,8 @@ class Application < Rails::Application # Enable Rack::Attack middleware for API rate limiting config.middleware.use Rack::Attack - config.x.ui = ActiveSupport::OrderedOptions.new - default_layout = ENV.fetch("DEFAULT_UI_LAYOUT", "dashboard") - config.x.ui.default_layout = default_layout.in?(%w[dashboard intro]) ? default_layout : "dashboard" - # Handle OmniAuth/OIDC errors gracefully (must be before OmniAuth middleware) - require_relative "../app/middleware/omniauth_error_handler" - config.middleware.use OmniauthErrorHandler + config.mission_control.jobs.http_basic_auth_enabled = false if Rails.env.development? + MissionControl::Jobs.http_basic_auth_user = "jobs" if Rails.env.production? + MissionControl::Jobs.http_basic_auth_password = ENV["MISSION_CONTROL_JOBS_PASSWORD"] || "jobs" if Rails.env.production? end end diff --git a/config/cable.yml b/config/cable.yml index fc5a1eb8efa..7ca155ef873 100644 --- a/config/cable.yml +++ b/config/cable.yml @@ -1,10 +1,21 @@ +# Async adapter only works within the same process, so for manually triggering cable updates from a console, +# and seeing results in the browser, you must do so from the web console (running inside the dev process), +# not a terminal started via bin/rails console! Add "console" to any action or any ERB template view +# to make the web console appear. + +default: &default + adapter: solid_cable + connects_to: + database: + writing: cable + polling_interval: 0.1.seconds + message_retention: 1.day + development: - adapter: async + <<: *default test: adapter: test production: - adapter: redis - url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> - channel_prefix: sure_production + <<: *default diff --git a/config/cache.yml b/config/cache.yml new file mode 100644 index 00000000000..946125a096f --- /dev/null +++ b/config/cache.yml @@ -0,0 +1,17 @@ +default: &default + store_options: + # Cap age of oldest cache entry to fulfill retention policies + max_age: <%= 1.hour.to_i %> + max_size: <%= 1.gigabytes %> + namespace: <%= Rails.env %> + +development: &development + <<: *default + database: cache + +test: + <<: *default + +production: &production + <<: *default + database: cache diff --git a/config/ci.rb b/config/ci.rb new file mode 100644 index 00000000000..795f52c7b20 --- /dev/null +++ b/config/ci.rb @@ -0,0 +1,22 @@ +# Run using bin/ci + +CI.run do + step "Setup", "bin/setup --skip-server" + + step "Style: Ruby", "bin/rubocop" + + step "Security: Importmap vulnerability audit", "bin/importmap audit" + step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error" + + step "Tests: Rails", "bin/rails test" + step "Tests: System", "bin/rails test:system" + step "Tests: Seeds", "env RAILS_ENV=test bin/rails db:seed:replant" + + # Optional: set a green GitHub commit status to unblock PR merge. + # Requires the `gh` CLI and `gh extension install basecamp/gh-signoff`. + # if success? + # step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff" + # else + # failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again." + # end +end diff --git a/config/database.yml b/config/database.yml index 5b249238a33..4f6a4ee9534 100644 --- a/config/database.yml +++ b/config/database.yml @@ -1,20 +1,50 @@ default: &default - adapter: postgresql - encoding: unicode - pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 3 } %> - host: <%= ENV.fetch("DB_HOST") { "127.0.0.1" } %> - port: <%= ENV.fetch("DB_PORT") { "5432" } %> - user: <%= ENV.fetch("POSTGRES_USER") { nil } %> - password: <%= ENV.fetch("POSTGRES_PASSWORD") { nil } %> + adapter: sqlite3 + timeout: 5000 + default_transaction_mode: IMMEDIATE + pragmas: + foreign_keys: "ON" development: - <<: *default - database: <%= ENV.fetch("POSTGRES_DB") { "sure_development" } %> + primary: + <<: *default + database: storage/development_primary.sqlite3 + extensions: + - <%= SQLean::UUID.to_path %> + cache: + <<: *default + database: storage/development_cache.sqlite3 + migrations_paths: db/cache_migrate + queue: + <<: *default + database: storage/development_queue.sqlite3 + migrations_paths: db/queue_migrate + cable: + <<: *default + database: storage/development_cable.sqlite3 + migrations_paths: db/cable_migrate test: <<: *default - database: <%= ENV.fetch("POSTGRES_DB") { "sure_test" } %> + database: storage/test_primary.sqlite3 + extensions: + - <%= SQLean::UUID.to_path %> production: - <<: *default - database: <%= ENV.fetch("POSTGRES_DB") { "sure_production" } %> + primary: + <<: *default + database: storage/production_primary.sqlite3 + extensions: + - <%= SQLean::UUID.to_path %> + cache: + <<: *default + database: storage/production_cache.sqlite3 + migrations_paths: db/cache_migrate + queue: + <<: *default + database: storage/production_queue.sqlite3 + migrations_paths: db/queue_migrate + cable: + <<: *default + database: storage/production_cable.sqlite3 + migrations_paths: db/cable_migrate diff --git a/config/environments/development.rb b/config/environments/development.rb index 553da47e3e8..1738775c8df 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -3,9 +3,7 @@ Rails.application.configure do # Settings specified here will take precedence over those in config/application.rb. - # In the development environment your application's code is reloaded any time - # it changes. This slows down response time but is perfect for development - # since you don't have to restart the web server when you make code changes. + # Make code changes take effect immediately without server restart. config.enable_reloading = true # Do not eager load code on boot. @@ -14,40 +12,34 @@ # Show full error reports. config.consider_all_requests_local = true - # Enable server timing + # Enable server timing. config.server_timing = true - # Enable/disable caching. By default caching is disabled. - # Run rails dev:cache to toggle caching. + # Enable/disable Action Controller caching. By default Action Controller caching is disabled. + # Run rails dev:cache to toggle Action Controller caching. if Rails.root.join("tmp/caching-dev.txt").exist? config.action_controller.perform_caching = true config.action_controller.enable_fragment_cache_logging = true - - config.cache_store = :memory_store - config.public_file_server.headers = { - "Cache-Control" => "public, max-age=#{2.days.to_i}" - } + config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" } else config.action_controller.perform_caching = false - - config.cache_store = :null_store end - # Store uploaded files on the local file system (see config/storage.yml for options). - config.active_storage.service = ENV.fetch("ACTIVE_STORAGE_SERVICE", "local").to_sym - config.after_initialize do - ActiveStorage::Current.url_options = { host: "localhost", port: 3000 } - end + # Change to :null_store to avoid any caching. + config.cache_store = :memory_store - # Set Active Storage URL expiration time to 7 days - config.active_storage.urls_expire_in = 7.days + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local # Don't care if the mailer can't send. config.action_mailer.raise_delivery_errors = false config.action_mailer.delivery_method = :letter_opener + + # Make template changes take effect immediately. config.action_mailer.perform_caching = false + # Set localhost to be used by links generated in mailer templates. config.action_mailer.perform_deliveries = true config.action_mailer.default_url_options = { host: "localhost", port: ENV.fetch("PORT") { 3000 } } @@ -55,18 +47,26 @@ # Print deprecation notices to the Rails logger. config.active_support.deprecation = :log - # Raise exceptions for disallowed deprecations. - config.active_support.disallowed_deprecation = :raise - - # Tell Active Support which deprecation messages to disallow. - config.active_support.disallowed_deprecation_warnings = [] - # Raise an error on page load if there are pending migrations. config.active_record.migration_error = :page_load - config.assets.quiet = true + # Highlight code that triggered database queries in logs. config.active_record.verbose_query_logs = true + + # Append comments with runtime information tags to SQL queries in logs. + config.active_record.query_log_tags_enabled = true + + # Highlight code that enqueued background job in logs. config.active_job.verbose_enqueue_logs = true + config.active_job.queue_adapter = :solid_queue + config.solid_queue.connects_to = { database: { writing: :queue } } + config.solid_queue.logger = ActiveSupport::Logger.new(STDOUT) + + # Highlight code that triggered redirect in logs. + config.action_dispatch.verbose_redirect_logs = true + + # Suppress logger output for asset requests. + config.assets.quiet = true # Raises error for missing translations. # config.i18n.raise_on_missing_translations = true @@ -77,11 +77,11 @@ # Uncomment if you wish to allow Action Cable access from any origin. # config.action_cable.disable_request_forgery_protection = true - # Raise error when a before_action's only/except options reference missing actions + # Raise error when a before_action's only/except options reference missing actions. config.action_controller.raise_on_missing_callback_actions = true # Apply autocorrection by RuboCop to files generated by `bin/rails generate`. - config.generators.apply_rubocop_autocorrect_after_generate! + # config.generators.apply_rubocop_autocorrect_after_generate! # Allow connection from any host in development config.hosts = nil diff --git a/config/environments/production.rb b/config/environments/production.rb index fc7120a0a95..10f077a6ed9 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -6,47 +6,32 @@ # Code is not reloaded between requests. config.enable_reloading = false - # Eager load code on boot. This eager loads most of Rails and - # your application in memory, allowing both threaded web servers - # and those relying on copy on write to perform better. - # Rake tasks automatically ignore this option for performance. + # Eager load code on boot for better performance and memory savings (ignored by Rake tasks). config.eager_load = true - # Full error reports are disabled and caching is turned on. + # Full error reports are disabled. config.consider_all_requests_local = false - config.action_controller.perform_caching = true - # Ensures that a master key has been made available in ENV["RAILS_MASTER_KEY"], config/master.key, or an environment - # key such as config/credentials/production.key. This key is used to decrypt credentials (and other encrypted files). - # config.require_master_key = true + # Turn on fragment caching in view templates. + config.action_controller.perform_caching = true - # Disable serving static files from `public/`, relying on NGINX/Apache to do so instead. - # config.public_file_server.enabled = false + # Cache assets for far-future expiry since they are all digest stamped. + config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" } # Enable serving of images, stylesheets, and JavaScripts from an asset server. # config.asset_host = "http://assets.example.com" - # Specifies the header that your server uses for sending files. - # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache - # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX - # Store uploaded files on the local file system (see config/storage.yml for options). config.active_storage.service = ENV.fetch("ACTIVE_STORAGE_SERVICE", "local").to_sym - # Set Active Storage URL expiration time to 7 days - config.active_storage.urls_expire_in = 7.days - - # Mount Action Cable outside main process or domain. - # config.action_cable.mount_path = nil - # config.action_cable.url = "wss://example.com/cable" - # config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ] + # Assume all access to the app is happening through a SSL-terminating reverse proxy. + config.assume_ssl = ActiveModel::Type::Boolean.new.cast(ENV.fetch("RAILS_ASSUME_SSL", true)) # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. config.force_ssl = ActiveModel::Type::Boolean.new.cast(ENV.fetch("RAILS_FORCE_SSL", true)) - # Assume all access to the app is happening through a SSL-terminating reverse proxy. - # Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies. - config.assume_ssl = ActiveModel::Type::Boolean.new.cast(ENV.fetch("RAILS_ASSUME_SSL", true)) + # Skip http-to-https redirect for the default health check endpoint. + # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } # Log to Logtail if API key is present, otherwise log to STDOUT base_logger = if ENV["LOGTAIL_API_KEY"].present? && ENV["LOGTAIL_INGESTING_HOST"].present? @@ -55,60 +40,76 @@ ingesting_host: ENV["LOGTAIL_INGESTING_HOST"] ) else - ActiveSupport::Logger.new(STDOUT) - .tap { |logger| logger.formatter = ::Logger::Formatter.new } + ActiveSupport::TaggedLogging.logger(STDOUT) end config.logger = ActiveSupport::TaggedLogging.new(base_logger) - # Prepend all log lines with the following tags. config.log_tags = [ :request_id ] - # "info" includes generic and useful information about system operation, but avoids logging too much - # information to avoid inadvertent exposure of personally identifiable information (PII). If you - # want to log everything, set the level to "debug". + # Change to "debug" to log everything (including potentially personally-identifiable information!). config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") - if ENV["REDIS_URL"].present? - config.cache_store = :redis_cache_store, { url: ENV["REDIS_URL"] } - end + # Prevent health checks from clogging up the logs. + config.silence_healthcheck_path = "/up" - config.action_mailer.perform_caching = false - config.action_mailer.deliver_later_queue_name = :high_priority - config.action_mailer.default_url_options = { host: ENV["APP_DOMAIN"] } - config.action_mailer.delivery_method = :smtp - config.action_mailer.smtp_settings = { - address: ENV["SMTP_ADDRESS"], - port: ENV["SMTP_PORT"], - user_name: ENV["SMTP_USERNAME"], - password: ENV["SMTP_PASSWORD"], - tls: ENV["SMTP_TLS_ENABLED"] == "true", - openssl_verify_mode: ENV["SMTP_TLS_SKIP_VERIFY"] == "true" ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER, - ca_file: ENV["SSL_CA_FILE"] - } + # Don't log any deprecations. + config.active_support.report_deprecations = false + + # Replace the default in-process memory cache store with a durable alternative. + # config.cache_store = :mem_cache_store + + # Replace the default in-process and non-durable queuing backend for Active Job. + # config.active_job.queue_adapter = :resque # Ignore bad email addresses and do not raise email delivery errors. # Set this to true and configure the email server for immediate delivery to raise delivery errors. # config.action_mailer.raise_delivery_errors = false + # Set host to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "example.com" } + + # Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit. + # config.action_mailer.smtp_settings = { + # user_name: Rails.application.credentials.dig(:smtp, :user_name), + # password: Rails.application.credentials.dig(:smtp, :password), + # address: "smtp.example.com", + # port: 587, + # authentication: :plain + # } + # Enable locale fallbacks for I18n (makes lookups for any locale fall back to # the I18n.default_locale when a translation cannot be found). config.i18n.fallbacks = true - # Don't log any deprecations. - config.active_support.report_deprecations = false - # Do not dump schema after migrations. config.active_record.dump_schema_after_migration = false + # Only use :id for inspections in production. + config.active_record.attributes_for_inspect = [ :id ] + # Enable DNS rebinding protection and other `Host` header attacks. # config.hosts = [ # "example.com", # Allow requests from example.com # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` # ] + # # Skip DNS rebinding protection for the default health check endpoint. # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } - - # set REDIS_URL for Sidekiq to use Redis - config.active_job.queue_adapter = :sidekiq + config.cache_store = :solid_cache_store + config.active_job.queue_adapter = :solid_queue + config.solid_queue.connects_to = { database: { writing: :queue } } + config.action_mailer.perform_caching = false + config.action_mailer.deliver_later_queue_name = :high_priority + config.action_mailer.default_url_options = { host: ENV["APP_DOMAIN"] } + config.action_mailer.delivery_method = :smtp + config.action_mailer.smtp_settings = { + address: ENV["SMTP_ADDRESS"], + port: ENV["SMTP_PORT"], + user_name: ENV["SMTP_USERNAME"], + password: ENV["SMTP_PASSWORD"], + tls: ENV["SMTP_TLS_ENABLED"] == "true", + openssl_verify_mode: ENV["SMTP_TLS_SKIP_VERIFY"] == "true" ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER, + ca_file: ENV["SSL_CA_FILE"] + } end diff --git a/config/environments/test.rb b/config/environments/test.rb index 362e5286443..431e076a847 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -17,11 +17,9 @@ # loading is working properly before deploying your code. config.eager_load = ENV["CI"].present? - # Configure public file server for tests with Cache-Control for performance. + # Configure public file server for tests with cache-control for performance. config.public_file_server.enabled = true - config.public_file_server.headers = { - "Cache-Control" => "public, max-age=#{1.hour.to_i}" - } + config.public_file_server.headers = { "cache-control" => "public, max-age=3600" } # Set default sender email for tests ENV["EMAIL_SENDER"] = "hello@example.com" @@ -40,13 +38,14 @@ # Store uploaded files on the local file system in a temporary directory. config.active_storage.service = :test - config.action_mailer.perform_caching = false - # Tell Action Mailer not to deliver emails to the real world. # The :test delivery method accumulates sent emails in the # ActionMailer::Base.deliveries array. config.action_mailer.delivery_method = :test + # Set host to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "example.com" } + # Print deprecation notices to the stderr. config.active_support.deprecation = :stderr @@ -64,7 +63,7 @@ # Annotate rendered view with file names. # config.action_view.annotate_rendered_view_with_filenames = true - # Raise error when a before_action's only/except options reference missing actions + # Raise error when a before_action's only/except options reference missing actions. config.action_controller.raise_on_missing_callback_actions = true config.active_record.encryption.primary_key = "test" diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb index d06e93b0970..a8687e82c3f 100644 --- a/config/initializers/assets.rb +++ b/config/initializers/assets.rb @@ -4,5 +4,6 @@ Rails.application.config.assets.version = "1.0" # Add additional assets to the asset load path. +# Rails.application.config.assets.paths << Emoji.images_path Rails.application.config.assets.paths << "app/components" Rails.application.config.importmap.cache_sweepers << Rails.root.join("app/components") diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index 834aa21186d..d1642495adc 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -21,6 +21,10 @@ # config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } # config.content_security_policy_nonce_directives = %w(script-src style-src) # +# # Automatically add `nonce` to `javascript_tag`, `javascript_include_tag`, and `stylesheet_link_tag` +# # if the corresponding directives are specified in `content_security_policy_nonce_directives`. +# # config.content_security_policy_nonce_auto = true +# # # Report violations without enforcing the policy. # # config.content_security_policy_report_only = true # end diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb index af9db03123f..f6028485d87 100644 --- a/config/initializers/filter_parameter_logging.rb +++ b/config/initializers/filter_parameter_logging.rb @@ -4,6 +4,5 @@ # Use this to limit dissemination of sensitive information. # See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. Rails.application.config.filter_parameters += [ - :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :openai_access_token, - :client_id, :consumer_key, :snaptrade_user_id, :snaptrade_user_secret + :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc, :openai_access_token ] diff --git a/config/initializers/generator.rb b/config/initializers/generator.rb index 034fab6e565..1111e2319a3 100644 --- a/config/initializers/generator.rb +++ b/config/initializers/generator.rb @@ -1,3 +1,3 @@ Rails.application.config.generators do |g| - g.orm :active_record, primary_key_type: :uuid + g.orm :active_record, primary_key_type: :string end diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb deleted file mode 100644 index 1338491a4f0..00000000000 --- a/config/initializers/sidekiq.rb +++ /dev/null @@ -1,78 +0,0 @@ -require "sidekiq/web" - -if Rails.env.production? - Sidekiq::Web.use(Rack::Auth::Basic) do |username, password| - configured_username = ::Digest::SHA256.hexdigest(ENV.fetch("SIDEKIQ_WEB_USERNAME", "sure")) - configured_password = ::Digest::SHA256.hexdigest(ENV.fetch("SIDEKIQ_WEB_PASSWORD", "sure")) - - ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(username), configured_username) & - ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(password), configured_password) - end -end - -# Configure Redis connection for Sidekiq -# Supports both Redis Sentinel (for HA) and direct Redis URL -redis_config = if ENV["REDIS_SENTINEL_HOSTS"].present? - # Redis Sentinel configuration for high availability - # REDIS_SENTINEL_HOSTS should be comma-separated list: "host1:port1,host2:port2,host3:port3" - sentinels = ENV["REDIS_SENTINEL_HOSTS"].split(",").filter_map do |host_port| - parts = host_port.strip.split(":", 2) - host = parts[0]&.strip - port_str = parts[1]&.strip - - next if host.blank? - - # Parse port with validation, default to 26379 if invalid or missing - port = if port_str.present? - port_int = port_str.to_i - (port_int > 0 && port_int <= 65535) ? port_int : 26379 - else - 26379 - end - - { host: host, port: port } - end - - if sentinels.empty? - Rails.logger.warn("REDIS_SENTINEL_HOSTS is set but no valid sentinel hosts found, falling back to REDIS_URL") - { url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0") } - else - { - url: "redis://#{ENV.fetch('REDIS_SENTINEL_MASTER', 'mymaster')}/0", - sentinels: sentinels, - password: ENV["REDIS_PASSWORD"], - sentinel_username: ENV.fetch("REDIS_SENTINEL_USERNAME", "default"), - sentinel_password: ENV["REDIS_PASSWORD"], - role: :master, - # Recommended timeouts for Sentinel - connect_timeout: 0.2, - read_timeout: 1, - write_timeout: 1, - reconnect_attempts: 3 - } - end -else - # Standard Redis URL configuration (no Sentinel) - { url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0") } -end - -Sidekiq.configure_server do |config| - config.redis = redis_config - - # Initialize auto-sync scheduler when Sidekiq server starts - config.on(:startup) do - AutoSyncScheduler.sync! - Rails.logger.info("[AutoSyncScheduler] Initialized sync_all_accounts cron job") - rescue => e - Rails.logger.error("[AutoSyncScheduler] Failed to initialize: #{e.message}") - end -end - -Sidekiq.configure_client do |config| - config.redis = redis_config -end - -Sidekiq::Cron.configure do |config| - # 10 min "catch-up" window in case worker process is re-deploying when cron tick occurs - config.reschedule_grace_period = 600 -end diff --git a/config/puma.rb b/config/puma.rb index 04022936aa1..ecdcdbd34b3 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -7,9 +7,14 @@ # Puma starts a configurable number of processes (workers) and each process # serves each request in a thread from an internal thread pool. # +# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You +# should only set this value when you want to run 2 or more workers. The +# default is already 1. You can set it to `auto` to automatically start a worker +# for each available processor. +# # The ideal number of threads per worker depends both on how much time the # application spends waiting for IO operations and on how much you wish to -# to prioritize throughput over latency. +# prioritize throughput over latency. # # As a rule of thumb, increasing the number of threads will increase how much # traffic a given process can handle (throughput), but due to CRuby's @@ -22,7 +27,7 @@ # Any libraries that use a connection pool or another resource pool should # be configured to provide at least as many connections as the number of # threads. This includes Active Record's `pool` parameter in `database.yml`. -threads_count = ENV.fetch("RAILS_MAX_THREADS") { 3 } +threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) threads threads_count, threads_count if rails_env == "production" @@ -39,9 +44,7 @@ end # Specifies the `port` that Puma will listen on to receive requests; default is 3000. -# The bind host is controlled via the Rails-native `BINDING` env var (set to -# `0.0.0.0` in containers, or `::` for IPv6 dual-stack). See docs/hosting/docker.md. -port ENV.fetch("PORT") { 3000 } +port ENV.fetch("PORT", 3000) # Specifies the `environment` that Puma will run in. environment rails_env @@ -49,6 +52,13 @@ # Allow puma to be restarted by `bin/rails restart` command. plugin :tmp_restart +# Run the Solid Queue supervisor inside of Puma for single-server deployments +plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"] || rails_env == "development" +litestream_enabled = ENV["LITESTREAM_ENABLED"] == "true" && rails_env == "production" +plugin :litestream if litestream_enabled + +# Specify the PID file. Defaults to tmp/pids/server.pid in development. +# In other environments, only set the PID file if requested. pidfile ENV["PIDFILE"] if ENV["PIDFILE"] if rails_env == "development" diff --git a/config/queue.yml b/config/queue.yml new file mode 100644 index 00000000000..32ac7ef40b5 --- /dev/null +++ b/config/queue.yml @@ -0,0 +1,18 @@ +default: &default + dispatchers: + - polling_interval: 1 + batch_size: 500 + workers: + - queues: "*" + threads: 2 + processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %> + polling_interval: 0.1 + +development: + <<: *default + +test: + <<: *default + +production: + <<: *default diff --git a/config/recurring.yml b/config/recurring.yml new file mode 100644 index 00000000000..d1459b4f31c --- /dev/null +++ b/config/recurring.yml @@ -0,0 +1,35 @@ +default: &default + clear_solid_queue_finished_jobs: + command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)" + schedule: every hour at minute 12 + + import_market_data: + class: "ImportMarketDataJob" + queue: "scheduled" + args: [{ mode: 'full', clear_cache: false }] + schedule: "0 22 * * 1-5" # 5:00 PM EST / 6:00 PM EDT (NY time) Monday through Friday + description: "Imports market data daily at 5:00 PM EST (1 hour after market close)" + + +production: + <<: *default + clean_syncs: + schedule: "0 * * * *" # every hour + class: "SyncCleanerJob" + queue: "scheduled" + description: "Cleans up stale syncs" + + run_security_health_checks: + schedule: "0 2 * * 1-5" # 2:00 AM EST / 3:00 AM EDT (NY time) Monday through Friday + class: "SecurityHealthCheckJob" + queue: "scheduled" + description: "Runs security health checks to detect issues with security data" + + seed_dump: + class: "SeedDumpJob" + queue: "scheduled" + description: "Runs seed dump manually" + schedule: "every month" + +development: + <<: *default \ No newline at end of file diff --git a/config/schedule.yml b/config/schedule.yml deleted file mode 100644 index c3903a22921..00000000000 --- a/config/schedule.yml +++ /dev/null @@ -1,44 +0,0 @@ -import_market_data: - cron: "0 22 * * 1-5" # 5:00 PM EST / 6:00 PM EDT (NY time) Monday through Friday - class: "ImportMarketDataJob" - queue: "scheduled" - description: "Imports market data daily at 5:00 PM EST (1 hour after market close)" - args: - mode: "full" - clear_cache: false - -clean_syncs: - cron: "0 * * * *" # every hour - class: "SyncCleanerJob" - queue: "scheduled" - description: "Cleans up stale syncs" - -run_security_health_checks: - cron: "0 2 * * 1-5" # 2:00 AM EST / 3:00 AM EDT (NY time) Monday through Friday - class: "SecurityHealthCheckJob" - queue: "scheduled" - description: "Runs security health checks to detect issues with security data" - -sync_hourly: - cron: "0 * * * *" # every hour at the top of the hour - class: "SyncHourlyJob" - queue: "scheduled" - description: "Syncs provider items that opt-in to hourly syncing" - -clean_data: - cron: "0 3 * * *" # daily at 3:00 AM - class: "DataCleanerJob" - queue: "scheduled" - description: "Cleans up old data (e.g., expired merchant associations, expired archived exports)" - -clean_inactive_families: - cron: "0 4 * * *" # daily at 4:00 AM - class: "InactiveFamilyCleanerJob" - queue: "scheduled" - description: "Archives and destroys families that expired their trial without subscribing (managed mode only)" - -refresh_demo_family: - cron: "0 5 * * *" # daily at 5:00 AM UTC - class: "DemoFamilyRefreshJob" - queue: "scheduled" - description: "Refreshes demo family data and emails super admins with daily usage summary" diff --git a/config/sidekiq.yml b/config/sidekiq.yml deleted file mode 100644 index 4fce6f00b5e..00000000000 --- a/config/sidekiq.yml +++ /dev/null @@ -1,7 +0,0 @@ -concurrency: <%= ENV.fetch("RAILS_MAX_THREADS") { 3 } %> -queues: - - [scheduled, 10] # For cron-like jobs (e.g. "daily market data sync") - - [high_priority, 4] - - [medium_priority, 2] - - [low_priority, 1] - - [default, 1] diff --git a/db/cable_schema.rb b/db/cable_schema.rb new file mode 100644 index 00000000000..3aefc381eec --- /dev/null +++ b/db/cable_schema.rb @@ -0,0 +1,23 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 1) do + create_table "solid_cable_messages", force: :cascade do |t| + t.binary "channel", limit: 1024, null: false + t.integer "channel_hash", limit: 8, null: false + t.datetime "created_at", null: false + t.binary "payload", limit: 536870912, null: false + t.index ["channel"], name: "index_solid_cable_messages_on_channel" + t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash" + t.index ["created_at"], name: "index_solid_cable_messages_on_created_at" + end +end diff --git a/db/cache_schema.rb b/db/cache_schema.rb new file mode 100644 index 00000000000..2016467a112 --- /dev/null +++ b/db/cache_schema.rb @@ -0,0 +1,24 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 1) do + create_table "solid_cache_entries", force: :cascade do |t| + t.integer "byte_size", limit: 4, null: false + t.datetime "created_at", null: false + t.binary "key", limit: 1024, null: false + t.integer "key_hash", limit: 8, null: false + t.binary "value", limit: 536870912, null: false + t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size" + t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size" + t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true + end +end diff --git a/db/migrate/20250924124734_change_uuid_to_string.rb b/db/migrate/20250924124734_change_uuid_to_string.rb new file mode 100644 index 00000000000..f0b12808152 --- /dev/null +++ b/db/migrate/20250924124734_change_uuid_to_string.rb @@ -0,0 +1,328 @@ +class ChangeUuidToString < ActiveRecord::Migration[7.2] + def change + # remove foreign key constraints + remove_all_foreign_keys + + # accounts + change_column :accounts, :id, :string, null: false + change_column :accounts, :family_id, :string, null: false + change_column :accounts, :accountable_id, :string + change_column :accounts, :import_id, :string + change_column :accounts, :plaid_account_id, :string + change_column :accounts, :simplefin_account_id, :string + + # active_storage_attachments + change_column :active_storage_attachments, :id, :string, null: false + change_column :active_storage_attachments, :record_id, :string, null: false + change_column :active_storage_attachments, :blob_id, :string, null: false + + # active_storage_blobs + change_column :active_storage_blobs, :id, :string, null: false + + # active_storage_variant_records + change_column :active_storage_variant_records, :id, :string, null: false + change_column :active_storage_variant_records, :blob_id, :string, null: false + + # addresses + change_column :addresses, :id, :string, null: false + change_column :addresses, :addressable_id, :string + + # api_keys + change_column :api_keys, :id, :string, null: false + change_column :api_keys, :user_id, :string, null: false + + # balances + change_column :balances, :id, :string, null: false + change_column :balances, :account_id, :string, null: false + + # budget_categories + change_column :budget_categories, :id, :string, null: false + change_column :budget_categories, :budget_id, :string, null: false + change_column :budget_categories, :category_id, :string, null: false + + # budgets + change_column :budgets, :id, :string, null: false + change_column :budgets, :family_id, :string, null: false + + # categories + change_column :categories, :id, :string, null: false + change_column :categories, :family_id, :string, null: false + change_column :categories, :parent_id, :string + + # chats + change_column :chats, :id, :string, null: false + change_column :chats, :user_id, :string, null: false + change_column :chats, :title, :string, null: false + + # credit_cards + change_column :credit_cards, :id, :string, null: false + # cryptos + change_column :cryptos, :id, :string, null: false + + # data_enrichments + change_column :data_enrichments, :id, :string, null: false + change_column :data_enrichments, :enrichable_id, :string, null: false + # depositories + change_column :depositories, :id, :string, null: false + # entries + change_column :entries, :id, :string, null: false + change_column :entries, :account_id, :string, null: false + change_column :entries, :entryable_id, :string + change_column :entries, :import_id, :string + # exchange_rates + change_column :exchange_rates, :id, :string, null: false + # families + change_column :families, :id, :string, null: false + # family_exports + change_column :family_exports, :id, :string, null: false + change_column :family_exports, :family_id, :string, null: false + # holdings + change_column :holdings, :id, :string, null: false + change_column :holdings, :account_id, :string, null: false + change_column :holdings, :security_id, :string, null: false + # impersonation_session_logs + change_column :impersonation_session_logs, :id, :string, null: false + change_column :impersonation_session_logs, :impersonation_session_id, :string, null: false + # impersonation_sessions + change_column :impersonation_sessions, :id, :string, null: false + change_column :impersonation_sessions, :impersonator_id, :string, null: false + change_column :impersonation_sessions, :impersonated_id, :string, null: false + # import_mappings + change_column :import_mappings, :id, :string, null: false + change_column :import_mappings, :import_id, :string, null: false + change_column :import_mappings, :mappable_id, :string + # import_rows + change_column :import_rows, :id, :string, null: false + change_column :import_rows, :import_id, :string, null: false + # imports + change_column :imports, :id, :string, null: false + change_column :imports, :family_id, :string, null: false + change_column :imports, :account_id, :string + # investments + change_column :investments, :id, :string, null: false + # invitations + change_column :invitations, :id, :string, null: false + change_column :invitations, :family_id, :string, null: false + change_column :invitations, :inviter_id, :string, null: false + # invite_codes + change_column :invite_codes, :id, :string, null: false + # loans + change_column :loans, :id, :string, null: false + # merchants + change_column :merchants, :id, :string, null: false + change_column :merchants, :family_id, :string + # messages + change_column :messages, :id, :string, null: false + change_column :messages, :chat_id, :string, null: false + # mobile_devices + change_column :mobile_devices, :id, :string, null: false + change_column :mobile_devices, :user_id, :string, null: false + # oauth_access_grants + # change_column :oauth_access_grants, :id, :string, null: false + # oauth_access_tokens + # change_column :oauth_access_tokens, :id, :string, null: false + # oauth_applications + # change_column :oauth_applications, :id, :string, null: false + change_column :oauth_applications, :owner_id, :string + # other_assets + change_column :other_assets, :id, :string, null: false + # other_liabilities + change_column :other_liabilities, :id, :string, null: false + # plaid_accounts + change_column :plaid_accounts, :id, :string, null: false + change_column :plaid_accounts, :plaid_item_id, :string, null: false + # plaid_items + change_column :plaid_items, :id, :string, null: false + change_column :plaid_items, :family_id, :string, null: false + # properties + change_column :properties, :id, :string, null: false + + # rejected_transfers + change_column :rejected_transfers, :id, :string, null: false + change_column :rejected_transfers, :inflow_transaction_id, :string, null: false + change_column :rejected_transfers, :outflow_transaction_id, :string, null: false + # rule_actions + change_column :rule_actions, :id, :string, null: false + change_column :rule_actions, :rule_id, :string, null: false + # rule_conditions + change_column :rule_conditions, :id, :string, null: false + change_column :rule_conditions, :rule_id, :string + change_column :rule_conditions, :parent_id, :string + # rules + change_column :rules, :id, :string, null: false + change_column :rules, :family_id, :string, null: false + # securities + change_column :securities, :id, :string, null: false + # security_prices + change_column :security_prices, :id, :string, null: false + change_column :security_prices, :security_id, :string + # sessions + change_column :sessions, :id, :string, null: false + change_column :sessions, :user_id, :string, null: false + change_column :sessions, :active_impersonator_session_id, :string + # settings + # change_column :settings, :id, :string, null: false + # simplefin_accounts + change_column :simplefin_accounts, :id, :string, null: false + change_column :simplefin_accounts, :simplefin_item_id, :string, null: false + # simplefin_items + change_column :simplefin_items, :id, :string, null: false + change_column :simplefin_items, :family_id, :string, null: false + # subscriptions + change_column :subscriptions, :id, :string, null: false + change_column :subscriptions, :family_id, :string, null: false + # syncs + change_column :syncs, :id, :string, null: false + change_column :syncs, :syncable_id, :string, null: false + change_column :syncs, :parent_id, :string + # taggings + change_column :taggings, :id, :string, null: false + change_column :taggings, :tag_id, :string, null: false + change_column :taggings, :taggable_id, :string + # tags + change_column :tags, :id, :string, null: false + change_column :tags, :family_id, :string, null: false + # tool_calls + change_column :tool_calls, :id, :string, null: false + change_column :tool_calls, :message_id, :string, null: false + # trades + change_column :trades, :id, :string, null: false + change_column :trades, :security_id, :string, null: false + # transactions + change_column :transactions, :id, :string, null: false + change_column :transactions, :category_id, :string + change_column :transactions, :merchant_id, :string + # transfers + change_column :transfers, :id, :string, null: false + change_column :transfers, :inflow_transaction_id, :string, null: false + change_column :transfers, :outflow_transaction_id, :string, null: false + # users + change_column :users, :id, :string, null: false + change_column :users, :family_id, :string, null: false + change_column :users, :last_viewed_chat_id, :string + + # valuations + change_column :valuations, :id, :string, null: false + # vehicles + change_column :vehicles, :id, :string, null: false + + add_all_foreign_keys + # raise "Not implemented yet, WHAT" + end + + def remove_all_foreign_keys + remove_foreign_key :accounts, :families + remove_foreign_key :accounts, :imports + remove_foreign_key :accounts, :plaid_accounts + remove_foreign_key :accounts, :simplefin_accounts + remove_foreign_key :active_storage_attachments, :active_storage_blobs + remove_foreign_key :active_storage_variant_records, :active_storage_blobs + remove_foreign_key :api_keys, :users + remove_foreign_key :balances, :accounts + remove_foreign_key :budget_categories, :budgets + remove_foreign_key :budget_categories, :categories + remove_foreign_key :budgets, :families + remove_foreign_key :categories, :families + remove_foreign_key :chats, :users + remove_foreign_key :entries, :accounts + remove_foreign_key :entries, :imports + remove_foreign_key :family_exports, :families + remove_foreign_key :holdings, :accounts + remove_foreign_key :holdings, :securities + remove_foreign_key :impersonation_session_logs, :impersonation_sessions + remove_foreign_key :impersonation_sessions, :users, column: :impersonated_id + remove_foreign_key :impersonation_sessions, :users, column: :impersonator_id + remove_foreign_key :import_rows, :imports + remove_foreign_key :imports, :families + remove_foreign_key :invitations, :families + remove_foreign_key :invitations, :users, column: :inviter_id + remove_foreign_key :merchants, :families + remove_foreign_key :messages, :chats + remove_foreign_key :mobile_devices, :users + remove_foreign_key :oauth_access_grants, :oauth_applications, column: :application_id + remove_foreign_key :oauth_access_tokens, :oauth_applications, column: :application_id + remove_foreign_key :plaid_accounts, :plaid_items + remove_foreign_key :plaid_items, :families + remove_foreign_key :rejected_transfers, :transactions, column: :inflow_transaction_id + remove_foreign_key :rejected_transfers, :transactions, column: :outflow_transaction_id + remove_foreign_key :rule_actions, :rules + remove_foreign_key :rule_conditions, :rule_conditions, column: :parent_id + remove_foreign_key :rule_conditions, :rules + remove_foreign_key :rules, :families + remove_foreign_key :security_prices, :securities + remove_foreign_key :sessions, :impersonation_sessions, column: :active_impersonator_session_id + remove_foreign_key :sessions, :users + remove_foreign_key :simplefin_accounts, :simplefin_items + remove_foreign_key :simplefin_items, :families + remove_foreign_key :subscriptions, :families + remove_foreign_key :syncs, :syncs, column: :parent_id + remove_foreign_key :taggings, :tags + remove_foreign_key :tags, :families + remove_foreign_key :tool_calls, :messages + remove_foreign_key :trades, :securities + remove_foreign_key :transactions, :categories + remove_foreign_key :transactions, :merchants + remove_foreign_key :transfers, :transactions, column: :inflow_transaction_id + remove_foreign_key :transfers, :transactions, column: :outflow_transaction_id + remove_foreign_key :users, :chats, column: :last_viewed_chat_id + remove_foreign_key :users, :families + end + + def add_all_foreign_keys + add_foreign_key :accounts, :families + add_foreign_key :accounts, :imports + add_foreign_key :accounts, :plaid_accounts + add_foreign_key :accounts, :simplefin_accounts + add_foreign_key :active_storage_attachments, :active_storage_blobs, column: :blob_id + add_foreign_key :active_storage_variant_records, :active_storage_blobs, column: :blob_id + add_foreign_key :api_keys, :users + add_foreign_key :balances, :accounts, on_delete: :cascade + add_foreign_key :budget_categories, :budgets + add_foreign_key :budget_categories, :categories + add_foreign_key :budgets, :families + add_foreign_key :categories, :families + add_foreign_key :chats, :users + add_foreign_key :entries, :accounts + add_foreign_key :entries, :imports + add_foreign_key :family_exports, :families + add_foreign_key :holdings, :accounts + add_foreign_key :holdings, :securities + add_foreign_key :impersonation_session_logs, :impersonation_sessions + add_foreign_key :impersonation_sessions, :users, column: :impersonated_id + add_foreign_key :impersonation_sessions, :users, column: :impersonator_id + add_foreign_key :import_rows, :imports + add_foreign_key :imports, :families + add_foreign_key :invitations, :families + add_foreign_key :invitations, :users, column: :inviter_id + add_foreign_key :merchants, :families + add_foreign_key :messages, :chats + add_foreign_key :mobile_devices, :users + add_foreign_key :oauth_access_grants, :oauth_applications, column: :application_id + add_foreign_key :oauth_access_tokens, :oauth_applications, column: :application_id + add_foreign_key :plaid_accounts, :plaid_items + add_foreign_key :plaid_items, :families + add_foreign_key :rejected_transfers, :transactions, column: :inflow_transaction_id + add_foreign_key :rejected_transfers, :transactions, column: :outflow_transaction_id + add_foreign_key :rule_actions, :rules + add_foreign_key :rule_conditions, :rule_conditions, column: :parent_id + add_foreign_key :rule_conditions, :rules + add_foreign_key :rules, :families + add_foreign_key :security_prices, :securities + add_foreign_key :sessions, :impersonation_sessions, column: :active_impersonator_session_id + add_foreign_key :sessions, :users + add_foreign_key :simplefin_accounts, :simplefin_items + add_foreign_key :simplefin_items, :families + add_foreign_key :subscriptions, :families + add_foreign_key :syncs, :syncs, column: :parent_id + add_foreign_key :taggings, :tags + add_foreign_key :tags, :families + add_foreign_key :tool_calls, :messages + add_foreign_key :trades, :securities + add_foreign_key :transactions, :categories, on_delete: :nullify + add_foreign_key :transactions, :merchants + add_foreign_key :transfers, :transactions, column: :inflow_transaction_id, on_delete: :cascade + add_foreign_key :transfers, :transactions, column: :outflow_transaction_id, on_delete: :cascade + add_foreign_key :users, :chats, column: :last_viewed_chat_id + add_foreign_key :users, :families + end +end diff --git a/db/migrate/20250924142630_change_json_b_to_json.rb b/db/migrate/20250924142630_change_json_b_to_json.rb new file mode 100644 index 00000000000..0a658b2a646 --- /dev/null +++ b/db/migrate/20250924142630_change_json_b_to_json.rb @@ -0,0 +1,54 @@ +class ChangeJsonBToJson < ActiveRecord::Migration[7.2] + def change + change_column :accounts, :locked_attributes, :json + change_column :chats, :error, :json + change_column :credit_cards, :locked_attributes, :json + change_column :cryptos, :locked_attributes, :json + + change_column :data_enrichments, :value, :json + change_column :data_enrichments, :metadata, :json + + change_column :depositories, :locked_attributes, :json + + change_column :entries, :locked_attributes, :json + + change_column :imports, :column_mappings, :json + + change_column :investments, :locked_attributes, :json + + change_column :loans, :locked_attributes, :json + + change_column :other_assets, :locked_attributes, :json + + change_column :other_liabilities, :locked_attributes, :json + + change_column :plaid_accounts, :raw_payload, :json + change_column :plaid_accounts, :raw_transactions_payload, :json + change_column :plaid_accounts, :raw_investments_payload, :json + change_column :plaid_accounts, :raw_liabilities_payload, :json + + change_column :plaid_items, :raw_payload, :json + change_column :plaid_items, :raw_institution_payload, :json + + change_column :properties, :locked_attributes, :json + + change_column :sessions, :prev_transaction_page_params, :json + change_column :sessions, :data, :json + + change_column :simplefin_accounts, :raw_payload, :json + change_column :simplefin_accounts, :raw_transactions_payload, :json + change_column :simplefin_accounts, :extra, :json + change_column :simplefin_accounts, :org_data, :json + + change_column :simplefin_items, :raw_payload, :json + change_column :simplefin_items, :raw_institution_payload, :json + + change_column :syncs, :data, :json + + change_column :tool_calls, :function_arguments, :json + change_column :tool_calls, :function_result, :json + + change_column :trades, :locked_attributes, :json + change_column :transactions, :locked_attributes, :json + end +end diff --git a/db/migrate/20250924190901_convert_accounts_classification_to_regular_column.rb b/db/migrate/20250924190901_convert_accounts_classification_to_regular_column.rb new file mode 100644 index 00000000000..bdf38669fb3 --- /dev/null +++ b/db/migrate/20250924190901_convert_accounts_classification_to_regular_column.rb @@ -0,0 +1,19 @@ +class ConvertAccountsClassificationToRegularColumn < ActiveRecord::Migration[7.0] + def change + # Remove the virtual column + remove_column :accounts, :classification, :string + + # Add a regular string column + add_column :accounts, :classification, :string + + # Populate the classification column based on accountable_type + execute <<-SQL + UPDATE accounts#{' '} + SET classification = CASE + WHEN accountable_type IN ('Loan', 'CreditCard', 'OtherLiability') + THEN 'liability' + ELSE 'asset' + END + SQL + end +end diff --git a/db/migrate/20250924191112_convert_account_balances_virtual_columns_to_regular_columns.rb b/db/migrate/20250924191112_convert_account_balances_virtual_columns_to_regular_columns.rb new file mode 100644 index 00000000000..c02d4811e53 --- /dev/null +++ b/db/migrate/20250924191112_convert_account_balances_virtual_columns_to_regular_columns.rb @@ -0,0 +1,25 @@ +class ConvertAccountBalancesVirtualColumnsToRegularColumns < ActiveRecord::Migration[7.0] + def change + # Remove all virtual columns + remove_column :balances, :start_balance, :decimal + remove_column :balances, :end_cash_balance, :decimal + remove_column :balances, :end_non_cash_balance, :decimal + remove_column :balances, :end_balance, :decimal + + # Add regular decimal columns + add_column :balances, :start_balance, :decimal, precision: 19, scale: 4 + add_column :balances, :end_cash_balance, :decimal, precision: 19, scale: 4 + add_column :balances, :end_non_cash_balance, :decimal, precision: 19, scale: 4 + add_column :balances, :end_balance, :decimal, precision: 19, scale: 4 + + # Populate the columns based on the original virtual column logic + execute <<-SQL + UPDATE balances#{' '} + SET#{' '} + start_balance = (start_cash_balance + start_non_cash_balance), + end_cash_balance = ((start_cash_balance + ((cash_inflows - cash_outflows) * (flows_factor)::numeric)) + cash_adjustments), + end_non_cash_balance = (((start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * (flows_factor)::numeric)) + net_market_flows) + non_cash_adjustments), + end_balance = (((start_cash_balance + ((cash_inflows - cash_outflows) * (flows_factor)::numeric)) + cash_adjustments) + (((start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * (flows_factor)::numeric)) + net_market_flows) + non_cash_adjustments)) + SQL + end +end diff --git a/db/migrate/20250924194505_remove_functional_and_partial_indexes.rb b/db/migrate/20250924194505_remove_functional_and_partial_indexes.rb new file mode 100644 index 00000000000..0eb4753956a --- /dev/null +++ b/db/migrate/20250924194505_remove_functional_and_partial_indexes.rb @@ -0,0 +1,25 @@ +class RemoveFunctionalAndPartialIndexes < ActiveRecord::Migration[7.0] + def change + # Remove functional index on entries.name + remove_index :entries, name: "index_entries_on_lower_name" + + # Remove partial indexes on merchants + remove_index :merchants, name: "index_merchants_on_family_id_and_name" + remove_index :merchants, name: "index_merchants_on_source_and_name" + + # Remove partial index on users.otp_secret + remove_index :users, name: "index_users_on_otp_secret" + + # Add regular indexes to replace them + + # Regular index on entries.name (for case-insensitive searches, handle in application) + add_index :entries, :name, name: "index_entries_on_name" + + # Regular indexes on merchants (remove unique constraint since we can't enforce it per type) + add_index :merchants, [ :family_id, :name ], name: "index_merchants_on_family_id_and_name" + add_index :merchants, [ :source, :name ], name: "index_merchants_on_source_and_name" + + # Regular index on users.otp_secret (remove unique constraint since we can't enforce it for non-null only) + add_index :users, :otp_secret, name: "index_users_on_otp_secret" + end +end diff --git a/db/migrate/20250924215505_convert_billed_products_array_to_json.rb b/db/migrate/20250924215505_convert_billed_products_array_to_json.rb new file mode 100644 index 00000000000..2ff85d1909e --- /dev/null +++ b/db/migrate/20250924215505_convert_billed_products_array_to_json.rb @@ -0,0 +1,30 @@ +class ConvertBilledProductsArrayToJson < ActiveRecord::Migration[7.0] + def change + # Remove PostgreSQL array columns + remove_column :plaid_items, :available_products, :string, array: true + remove_column :plaid_items, :billed_products, :string, array: true + remove_column :users, :otp_backup_codes, :string, array: true + remove_column :users, :goals, :text, array: true + + # Add JSON columns with default empty array + add_column :plaid_items, :available_products, :json, default: [] + add_column :plaid_items, :billed_products, :json, default: [] + add_column :users, :otp_backup_codes, :json, default: [] + add_column :users, :goals, :json, default: [] + + # Convert JSONB to JSON for database compatibility + remove_column :valuations, :locked_attributes, :jsonb + remove_column :vehicles, :locked_attributes, :jsonb + + add_column :valuations, :locked_attributes, :json, default: {} + add_column :vehicles, :locked_attributes, :json, default: {} + + # Remove PostgreSQL-specific functional index + remove_index :securities, name: "index_securities_on_ticker_and_exchange_operating_mic_unique" + + # Add regular index instead + add_index :securities, [ :ticker, :exchange_operating_mic ], + name: "index_securities_on_ticker_and_exchange_operating_mic_unique", + unique: true + end +end diff --git a/db/migrate/20250927190818_remove_uuid_default_value.rb b/db/migrate/20250927190818_remove_uuid_default_value.rb new file mode 100644 index 00000000000..75963b78447 --- /dev/null +++ b/db/migrate/20250927190818_remove_uuid_default_value.rb @@ -0,0 +1,88 @@ + +class RemoveUuidDefaultValue < ActiveRecord::Migration[7.2] + def up + # Remove PostgreSQL-specific UUID defaults for SQLite compatibility + # SQLite doesn't support gen_random_uuid(), so we'll handle UUIDs in Ruby + + # List of tables that need UUID default removal + tables_with_uuid_defaults = [ + 'accounts', + 'active_storage_attachments', + 'active_storage_blobs', + 'active_storage_variant_records', + 'addresses', + 'api_keys', + 'balances', + 'budget_categories', + 'budgets', + 'categories', + 'chats', + 'credit_cards', + 'cryptos', + 'data_enrichments', + 'depositories', + 'entries', + 'exchange_rates', + 'families', + 'family_exports', + 'holdings', + 'impersonation_session_logs', + 'impersonation_sessions', + 'import_mappings', + 'import_rows', + 'imports', + 'investments', + 'invitations', + 'invite_codes', + 'loans', + 'merchants', + 'messages', + 'mobile_devices', + # 'oauth_access_grants', + # 'oauth_access_tokens', + # 'oauth_applications', + 'other_assets', + 'other_liabilities', + 'plaid_accounts', + 'plaid_items', + 'properties', + 'rejected_transfers', + 'rule_actions', + 'rule_conditions', + 'rules', + 'securities', + 'security_prices', + 'sessions', + # 'settings', + 'simplefin_accounts', + 'simplefin_items', + 'subscriptions', + 'syncs', + 'taggings', + 'tags', + 'tool_calls', + 'trades', + 'transactions', + 'transfers', + 'users', + 'valuations', + 'vehicles' + ] + + tables_with_uuid_defaults.each do |table_name| + if table_exists?(table_name) + # Remove the default UUID generation for SQLite compatibility + change_column_default table_name, :id, nil + puts "Removed UUID default from #{table_name}" + else + raise ActiveRecord::MigrationError, "Table #{table_name} does not exist" + end + end + end + + def down + # This migration is not reversible as we can't restore PostgreSQL-specific defaults + # in a SQLite environment + # raise ActiveRecord::IrreversibleMigration, "Cannot restore PostgreSQL UUID defaults in SQLite" + end +end diff --git a/db/migrate/20250928213010_add_uuid_defaults_to_active_storage_tables.rb b/db/migrate/20250928213010_add_uuid_defaults_to_active_storage_tables.rb new file mode 100644 index 00000000000..d80a63c1272 --- /dev/null +++ b/db/migrate/20250928213010_add_uuid_defaults_to_active_storage_tables.rb @@ -0,0 +1,8 @@ +class AddUuidDefaultsToActiveStorageTables < ActiveRecord::Migration[7.2] + def change + # Add UUID default values for Active Storage tables primary keys + change_column_default :active_storage_attachments, :id, -> { "gen_random_uuid()" } + change_column_default :active_storage_blobs, :id, -> { "gen_random_uuid()" } + change_column_default :active_storage_variant_records, :id, -> { "gen_random_uuid()" } + end +end diff --git a/db/migrate/20250928220425_add_uuid_default_to_categories_table.rb b/db/migrate/20250928220425_add_uuid_default_to_categories_table.rb new file mode 100644 index 00000000000..592b3fe6a25 --- /dev/null +++ b/db/migrate/20250928220425_add_uuid_default_to_categories_table.rb @@ -0,0 +1,6 @@ +class AddUuidDefaultToCategoriesTable < ActiveRecord::Migration[7.2] + def change + # Add UUID default value for categories table primary key + change_column_default :categories, :id, -> { "gen_random_uuid()" } + end +end diff --git a/db/migrate/20250929215928_add_uuid_defaults_to_all_tables.rb b/db/migrate/20250929215928_add_uuid_defaults_to_all_tables.rb new file mode 100644 index 00000000000..1abe72d610a --- /dev/null +++ b/db/migrate/20250929215928_add_uuid_defaults_to_all_tables.rb @@ -0,0 +1,77 @@ +class AddUuidDefaultsToAllTables < ActiveRecord::Migration[7.2] + def change + # Remove PostgreSQL-specific UUID defaults for SQLite compatibility + # SQLite doesn't support gen_random_uuid(), so we'll handle UUIDs in Ruby + + # List of tables that need UUID default removal + tables_with_uuid_defaults = [ + 'accounts', + 'addresses', + 'api_keys', + 'balances', + 'budget_categories', + 'budgets', + 'chats', + 'credit_cards', + 'cryptos', + 'data_enrichments', + 'depositories', + 'entries', + 'exchange_rates', + 'families', + 'family_exports', + 'holdings', + 'impersonation_session_logs', + 'impersonation_sessions', + 'import_mappings', + 'import_rows', + 'imports', + 'investments', + 'invitations', + 'invite_codes', + 'loans', + 'merchants', + 'messages', + 'mobile_devices', + # 'oauth_access_grants', + # 'oauth_access_tokens', + # 'oauth_applications', + 'other_assets', + 'other_liabilities', + 'plaid_accounts', + 'plaid_items', + 'properties', + 'rejected_transfers', + 'rule_actions', + 'rule_conditions', + 'rules', + 'securities', + 'security_prices', + 'sessions', + # 'settings', + 'simplefin_accounts', + 'simplefin_items', + 'subscriptions', + 'syncs', + 'taggings', + 'tags', + 'tool_calls', + 'trades', + 'transactions', + 'transfers', + 'users', + 'valuations', + 'vehicles' + ] + + tables_with_uuid_defaults.each do |table_name| + if table_exists?(table_name) + # Remove the default UUID generation for SQLite compatibility + change_column_default table_name, :id, nil + puts "Removed UUID default from #{table_name}" + else + raise ActiveRecord::MigrationError, "Table #{table_name} does not exist" + end + end + end +end diff --git a/db/migrate/20250930191642_change_default_id_to_ulid.rb b/db/migrate/20250930191642_change_default_id_to_ulid.rb new file mode 100644 index 00000000000..20d7a0da3aa --- /dev/null +++ b/db/migrate/20250930191642_change_default_id_to_ulid.rb @@ -0,0 +1,17 @@ +class ChangeDefaultIdToUlid < ActiveRecord::Migration[7.2] + def up + # Change default value from gen_random_uuid() to ulid() for tables that use it + change_column_default :active_storage_attachments, :id, -> { "ulid()" } + change_column_default :active_storage_blobs, :id, -> { "ulid()" } + change_column_default :active_storage_variant_records, :id, -> { "ulid()" } + change_column_default :categories, :id, -> { "ulid()" } + end + + def down + # Revert back to gen_random_uuid() if needed + change_column_default :active_storage_attachments, :id, -> { "gen_random_uuid()" } + change_column_default :active_storage_blobs, :id, -> { "gen_random_uuid()" } + change_column_default :active_storage_variant_records, :id, -> { "gen_random_uuid()" } + change_column_default :categories, :id, -> { "gen_random_uuid()" } + end +end diff --git a/db/migrate/20251003011536_add_service_name_to_active_storage_blobs.active_storage.rb b/db/migrate/20251003011536_add_service_name_to_active_storage_blobs.active_storage.rb new file mode 100644 index 00000000000..a15c6ce8e58 --- /dev/null +++ b/db/migrate/20251003011536_add_service_name_to_active_storage_blobs.active_storage.rb @@ -0,0 +1,22 @@ +# This migration comes from active_storage (originally 20190112182829) +class AddServiceNameToActiveStorageBlobs < ActiveRecord::Migration[6.0] + def up + return unless table_exists?(:active_storage_blobs) + + unless column_exists?(:active_storage_blobs, :service_name) + add_column :active_storage_blobs, :service_name, :string + + if configured_service = ActiveStorage::Blob.service.name + ActiveStorage::Blob.unscoped.update_all(service_name: configured_service) + end + + change_column :active_storage_blobs, :service_name, :string, null: false + end + end + + def down + return unless table_exists?(:active_storage_blobs) + + remove_column :active_storage_blobs, :service_name + end +end diff --git a/db/migrate/20251003011537_create_active_storage_variant_records.active_storage.rb b/db/migrate/20251003011537_create_active_storage_variant_records.active_storage.rb new file mode 100644 index 00000000000..4806b10e8e6 --- /dev/null +++ b/db/migrate/20251003011537_create_active_storage_variant_records.active_storage.rb @@ -0,0 +1,27 @@ +# This migration comes from active_storage (originally 20191206030411) +class CreateActiveStorageVariantRecords < ActiveRecord::Migration[6.0] + def change + return unless table_exists?(:active_storage_blobs) + + # Use Active Record's configured type for primary key + create_table :active_storage_variant_records, id: primary_key_type, if_not_exists: true do |t| + t.belongs_to :blob, null: false, index: false, type: blobs_primary_key_type + t.string :variation_digest, null: false + + t.index %i[blob_id variation_digest], name: "index_active_storage_variant_records_uniqueness", unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + end + + private + def primary_key_type + config = Rails.configuration.generators + config.options[config.orm][:primary_key_type] || :primary_key + end + + def blobs_primary_key_type + pkey_name = connection.primary_key(:active_storage_blobs) + pkey_column = connection.columns(:active_storage_blobs).find { |c| c.name == pkey_name } + pkey_column.bigint? ? :bigint : pkey_column.type + end +end diff --git a/db/migrate/20251003011538_remove_not_null_on_active_storage_blobs_checksum.active_storage.rb b/db/migrate/20251003011538_remove_not_null_on_active_storage_blobs_checksum.active_storage.rb new file mode 100644 index 00000000000..93c8b85ade5 --- /dev/null +++ b/db/migrate/20251003011538_remove_not_null_on_active_storage_blobs_checksum.active_storage.rb @@ -0,0 +1,8 @@ +# This migration comes from active_storage (originally 20211119233751) +class RemoveNotNullOnActiveStorageBlobsChecksum < ActiveRecord::Migration[6.0] + def change + return unless table_exists?(:active_storage_blobs) + + change_column_null(:active_storage_blobs, :checksum, true) + end +end diff --git a/db/migrate/20251008015408_update_id_defaults_to_gen_random_uuid.rb b/db/migrate/20251008015408_update_id_defaults_to_gen_random_uuid.rb new file mode 100644 index 00000000000..312679b9d12 --- /dev/null +++ b/db/migrate/20251008015408_update_id_defaults_to_gen_random_uuid.rb @@ -0,0 +1,27 @@ +class UpdateIdDefaultsToGenRandomUuid < ActiveRecord::Migration[8.0] + def change + # List of all tables with string ID columns that need gen_random_uuid() default + tables_with_string_ids = [ + 'accounts', 'addresses', 'api_keys', 'balances', 'budget_categories', 'budgets', + 'chats', 'credit_cards', 'cryptos', 'data_enrichments', 'depositories', 'entries', + 'exchange_rates', 'families', 'family_exports', 'holdings', 'impersonation_session_logs', + 'impersonation_sessions', 'import_mappings', 'import_rows', 'imports', 'investments', + 'invitations', 'invite_codes', 'loans', 'merchants', 'messages', 'mobile_devices', + 'other_assets', 'other_liabilities', 'plaid_accounts', 'plaid_items', 'properties', + 'rejected_transfers', 'rule_actions', 'rule_conditions', 'rules', 'securities', + 'security_prices', 'sessions', 'simplefin_accounts', 'simplefin_items', 'subscriptions', + 'syncs', 'taggings', 'tags', 'tool_calls', 'trades', 'transactions', 'transfers', + 'users', 'valuations', 'vehicles', + 'active_storage_attachments', 'active_storage_blobs', 'active_storage_variant_records', + 'categories' + ] + + tables_with_string_ids.each do |table_name| + # Check if table exists and has an id column + if table_exists?(table_name) && column_exists?(table_name, :id) + # Update the id column to use gen_random_uuid() as default + change_column_default table_name, :id, -> { "gen_random_uuid()" } + end + end + end +end diff --git a/db/queue_schema.rb b/db/queue_schema.rb new file mode 100644 index 00000000000..f56798cab3a --- /dev/null +++ b/db/queue_schema.rb @@ -0,0 +1,141 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 1) do + create_table "solid_queue_blocked_executions", force: :cascade do |t| + t.string "concurrency_key", null: false + t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.bigint "job_id", null: false + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.index ["concurrency_key", "priority", "job_id"], name: "index_solid_queue_blocked_executions_for_release" + t.index ["expires_at", "concurrency_key"], name: "index_solid_queue_blocked_executions_for_maintenance" + t.index ["job_id"], name: "index_solid_queue_blocked_executions_on_job_id", unique: true + end + + create_table "solid_queue_claimed_executions", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "job_id", null: false + t.bigint "process_id" + t.index ["job_id"], name: "index_solid_queue_claimed_executions_on_job_id", unique: true + t.index ["process_id", "job_id"], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" + end + + create_table "solid_queue_failed_executions", force: :cascade do |t| + t.datetime "created_at", null: false + t.text "error" + t.bigint "job_id", null: false + t.index ["job_id"], name: "index_solid_queue_failed_executions_on_job_id", unique: true + end + + create_table "solid_queue_jobs", force: :cascade do |t| + t.string "active_job_id" + t.text "arguments" + t.string "class_name", null: false + t.string "concurrency_key" + t.datetime "created_at", null: false + t.datetime "finished_at" + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.datetime "scheduled_at" + t.datetime "updated_at", null: false + t.index ["active_job_id"], name: "index_solid_queue_jobs_on_active_job_id" + t.index ["class_name"], name: "index_solid_queue_jobs_on_class_name" + t.index ["finished_at"], name: "index_solid_queue_jobs_on_finished_at" + t.index ["queue_name", "finished_at"], name: "index_solid_queue_jobs_for_filtering" + t.index ["scheduled_at", "finished_at"], name: "index_solid_queue_jobs_for_alerting" + end + + create_table "solid_queue_pauses", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "queue_name", null: false + t.index ["queue_name"], name: "index_solid_queue_pauses_on_queue_name", unique: true + end + + create_table "solid_queue_processes", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "hostname" + t.string "kind", null: false + t.datetime "last_heartbeat_at", null: false + t.text "metadata" + t.string "name", null: false + t.integer "pid", null: false + t.bigint "supervisor_id" + t.index ["last_heartbeat_at"], name: "index_solid_queue_processes_on_last_heartbeat_at" + t.index ["name", "supervisor_id"], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true + t.index ["supervisor_id"], name: "index_solid_queue_processes_on_supervisor_id" + end + + create_table "solid_queue_ready_executions", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "job_id", null: false + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.index ["job_id"], name: "index_solid_queue_ready_executions_on_job_id", unique: true + t.index ["priority", "job_id"], name: "index_solid_queue_poll_all" + t.index ["queue_name", "priority", "job_id"], name: "index_solid_queue_poll_by_queue" + end + + create_table "solid_queue_recurring_executions", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "job_id", null: false + t.datetime "run_at", null: false + t.string "task_key", null: false + t.index ["job_id"], name: "index_solid_queue_recurring_executions_on_job_id", unique: true + t.index ["task_key", "run_at"], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true + end + + create_table "solid_queue_recurring_tasks", force: :cascade do |t| + t.text "arguments" + t.string "class_name" + t.string "command", limit: 2048 + t.datetime "created_at", null: false + t.text "description" + t.string "key", null: false + t.integer "priority", default: 0 + t.string "queue_name" + t.string "schedule", null: false + t.boolean "static", default: true, null: false + t.datetime "updated_at", null: false + t.index ["key"], name: "index_solid_queue_recurring_tasks_on_key", unique: true + t.index ["static"], name: "index_solid_queue_recurring_tasks_on_static" + end + + create_table "solid_queue_scheduled_executions", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "job_id", null: false + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.datetime "scheduled_at", null: false + t.index ["job_id"], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true + t.index ["scheduled_at", "priority", "job_id"], name: "index_solid_queue_dispatch_all" + end + + create_table "solid_queue_semaphores", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.string "key", null: false + t.datetime "updated_at", null: false + t.integer "value", default: 1, null: false + t.index ["expires_at"], name: "index_solid_queue_semaphores_on_expires_at" + t.index ["key", "value"], name: "index_solid_queue_semaphores_on_key_and_value" + t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true + end + + add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade +end diff --git a/db/schema.rb b/db/schema.rb index 1c11eea005e..1437cb2bac8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,60 +10,24 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_05_03_180000) do - # These are extensions that must be enabled in order to support this database - enable_extension "pgcrypto" - enable_extension "plpgsql" - - # Custom types defined in this database. - # Note that some types may not work with other database engines. Be careful if changing database. - create_enum "account_status", ["ok", "syncing", "error"] - - create_table "account_providers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "account_id", null: false - t.string "provider_type", null: false - t.uuid "provider_id", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["account_id", "provider_type"], name: "index_account_providers_on_account_and_provider_type", unique: true - t.index ["provider_type", "provider_id"], name: "index_account_providers_on_provider_type_and_provider_id", unique: true - end - - create_table "account_shares", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "account_id", null: false - t.uuid "user_id", null: false - t.string "permission", default: "read_only", null: false - t.boolean "include_in_finances", default: true, null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["account_id", "user_id"], name: "index_account_shares_on_account_id_and_user_id", unique: true - t.index ["account_id"], name: "index_account_shares_on_account_id" - t.index ["user_id", "include_in_finances"], name: "index_account_shares_on_user_id_and_include_in_finances" - t.index ["user_id"], name: "index_account_shares_on_user_id" - t.check_constraint "permission::text = ANY (ARRAY['full_control'::character varying::text, 'read_write'::character varying::text, 'read_only'::character varying::text])", name: "chk_account_shares_permission" - end - - create_table "accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "subtype" - t.uuid "family_id", null: false - t.string "name" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false +ActiveRecord::Schema[8.1].define(version: 2025_10_08_015408) do + create_table "accounts", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "accountable_id" t.string "accountable_type" - t.uuid "accountable_id" t.decimal "balance", precision: 19, scale: 4 - t.string "currency" - t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Loan'::character varying)::text, ('CreditCard'::character varying)::text, ('OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true - t.uuid "import_id" - t.uuid "plaid_account_id" t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" - t.jsonb "locked_attributes", default: {} + t.string "classification" + t.datetime "created_at", null: false + t.string "currency" + t.string "family_id", null: false + t.string "import_id" + t.json "locked_attributes", default: {} + t.string "name" + t.string "plaid_account_id" + t.string "simplefin_account_id" t.string "status", default: "active" - t.uuid "simplefin_account_id" - t.string "institution_name" - t.string "institution_domain" - t.text "notes" - t.uuid "owner_id" + t.string "subtype" + t.datetime "updated_at", null: false t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type" t.index ["accountable_type"], name: "index_accounts_on_accountable_type" t.index ["currency"], name: "index_accounts_on_currency" @@ -79,569 +43,254 @@ t.index ["status"], name: "index_accounts_on_status" end - create_table "active_storage_attachments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + create_table "active_storage_attachments", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "blob_id", null: false + t.datetime "created_at", null: false t.string "name", null: false + t.string "record_id", null: false t.string "record_type", null: false - t.uuid "record_id", null: false - t.uuid "blob_id", null: false - t.datetime "created_at", null: false t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true end - create_table "active_storage_blobs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "key", null: false - t.string "filename", null: false - t.string "content_type" - t.text "metadata" - t.string "service_name", null: false + create_table "active_storage_blobs", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.bigint "byte_size", null: false t.string "checksum" + t.string "content_type" t.datetime "created_at", null: false + t.string "filename", null: false + t.string "key", null: false + t.text "metadata" + t.string "service_name", null: false t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true end - create_table "active_storage_variant_records", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "blob_id", null: false + create_table "active_storage_variant_records", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "blob_id", null: false t.string "variation_digest", null: false t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true end - create_table "addresses", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + create_table "addresses", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "addressable_id" t.string "addressable_type" - t.uuid "addressable_id" + t.string "country" + t.string "county" + t.datetime "created_at", null: false t.string "line1" t.string "line2" - t.string "county" t.string "locality" + t.integer "postal_code" t.string "region" - t.string "country" - t.string "postal_code" - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["addressable_type", "addressable_id"], name: "index_addresses_on_addressable" end - create_table "api_keys", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "name" - t.uuid "user_id", null: false - t.json "scopes" - t.datetime "last_used_at" - t.datetime "expires_at" - t.datetime "revoked_at" + create_table "api_keys", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.string "display_key", null: false + t.datetime "expires_at" + t.datetime "last_used_at" + t.string "name" + t.datetime "revoked_at" + t.json "scopes" t.string "source", default: "web" + t.datetime "updated_at", null: false + t.string "user_id", null: false t.index ["display_key"], name: "index_api_keys_on_display_key", unique: true t.index ["revoked_at"], name: "index_api_keys_on_revoked_at" t.index ["user_id", "source"], name: "index_api_keys_on_user_id_and_source" t.index ["user_id"], name: "index_api_keys_on_user_id" end - create_table "archived_exports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "email", null: false - t.string "family_name" - t.string "download_token_digest", null: false - t.datetime "expires_at", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["download_token_digest"], name: "index_archived_exports_on_download_token_digest", unique: true - t.index ["expires_at"], name: "index_archived_exports_on_expires_at" - end - - create_table "balances", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "account_id", null: false - t.date "date", null: false + create_table "balances", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "account_id", null: false t.decimal "balance", precision: 19, scale: 4, null: false - t.string "currency", default: "USD", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.decimal "cash_adjustments", precision: 19, scale: 4, default: "0.0", null: false t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" - t.decimal "start_cash_balance", precision: 19, scale: 4, default: "0.0", null: false - t.decimal "start_non_cash_balance", precision: 19, scale: 4, default: "0.0", null: false t.decimal "cash_inflows", precision: 19, scale: 4, default: "0.0", null: false t.decimal "cash_outflows", precision: 19, scale: 4, default: "0.0", null: false - t.decimal "non_cash_inflows", precision: 19, scale: 4, default: "0.0", null: false - t.decimal "non_cash_outflows", precision: 19, scale: 4, default: "0.0", null: false + t.datetime "created_at", null: false + t.string "currency", default: "USD", null: false + t.date "date", null: false + t.decimal "end_balance", precision: 19, scale: 4 + t.decimal "end_cash_balance", precision: 19, scale: 4 + t.decimal "end_non_cash_balance", precision: 19, scale: 4 + t.integer "flows_factor", default: 1, null: false t.decimal "net_market_flows", precision: 19, scale: 4, default: "0.0", null: false - t.decimal "cash_adjustments", precision: 19, scale: 4, default: "0.0", null: false t.decimal "non_cash_adjustments", precision: 19, scale: 4, default: "0.0", null: false - t.integer "flows_factor", default: 1, null: false - t.virtual "start_balance", type: :decimal, precision: 19, scale: 4, as: "(start_cash_balance + start_non_cash_balance)", stored: true - t.virtual "end_cash_balance", type: :decimal, precision: 19, scale: 4, as: "((start_cash_balance + ((cash_inflows - cash_outflows) * (flows_factor)::numeric)) + cash_adjustments)", stored: true - t.virtual "end_non_cash_balance", type: :decimal, precision: 19, scale: 4, as: "(((start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * (flows_factor)::numeric)) + net_market_flows) + non_cash_adjustments)", stored: true - t.virtual "end_balance", type: :decimal, precision: 19, scale: 4, as: "(((start_cash_balance + ((cash_inflows - cash_outflows) * (flows_factor)::numeric)) + cash_adjustments) + (((start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * (flows_factor)::numeric)) + net_market_flows) + non_cash_adjustments))", stored: true + t.decimal "non_cash_inflows", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "non_cash_outflows", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "start_balance", precision: 19, scale: 4 + t.decimal "start_cash_balance", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "start_non_cash_balance", precision: 19, scale: 4, default: "0.0", null: false + t.datetime "updated_at", null: false t.index ["account_id", "date", "currency"], name: "index_account_balances_on_account_id_date_currency_unique", unique: true t.index ["account_id", "date"], name: "index_balances_on_account_id_and_date", order: { date: :desc } t.index ["account_id"], name: "index_balances_on_account_id" end - create_table "binance_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "binance_item_id", null: false - t.string "name" - t.string "account_type" - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 - t.jsonb "institution_metadata" - t.jsonb "raw_payload" - t.jsonb "raw_transactions_payload" - t.jsonb "extra", default: {}, null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["account_type"], name: "index_binance_accounts_on_account_type" - t.index ["binance_item_id", "account_type"], name: "index_binance_accounts_on_item_and_type", unique: true - t.index ["binance_item_id"], name: "index_binance_accounts_on_binance_item_id" - end - - create_table "binance_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name" - t.string "institution_name" - t.string "institution_domain" - t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false - t.boolean "pending_account_setup", default: false - t.datetime "sync_start_date" - t.jsonb "raw_payload" - t.text "api_key" - t.text "api_secret" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["family_id"], name: "index_binance_items_on_family_id" - t.index ["status"], name: "index_binance_items_on_status" - end - - create_table "budget_categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "budget_id", null: false - t.uuid "category_id", null: false + create_table "budget_categories", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "budget_id", null: false t.decimal "budgeted_spending", precision: 19, scale: 4, null: false - t.string "currency", null: false + t.string "category_id", null: false t.datetime "created_at", null: false + t.string "currency", null: false t.datetime "updated_at", null: false t.index ["budget_id", "category_id"], name: "index_budget_categories_on_budget_id_and_category_id", unique: true t.index ["budget_id"], name: "index_budget_categories_on_budget_id" t.index ["category_id"], name: "index_budget_categories_on_category_id" end - create_table "budgets", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.date "start_date", null: false - t.date "end_date", null: false + create_table "budgets", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.decimal "budgeted_spending", precision: 19, scale: 4 - t.decimal "expected_income", precision: 19, scale: 4 - t.string "currency", null: false t.datetime "created_at", null: false + t.string "currency", null: false + t.date "end_date", null: false + t.decimal "expected_income", precision: 19, scale: 4 + t.string "family_id", null: false + t.date "start_date", null: false t.datetime "updated_at", null: false t.index ["family_id", "start_date", "end_date"], name: "index_budgets_on_family_id_and_start_date_and_end_date", unique: true t.index ["family_id"], name: "index_budgets_on_family_id" end - create_table "categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "name", null: false + create_table "categories", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "classification", default: "expense", null: false t.string "color", default: "#6172F3", null: false - t.uuid "family_id", null: false t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.uuid "parent_id" + t.string "family_id", null: false t.string "lucide_icon", default: "shapes", null: false - t.string "classification_unused", default: "expense", null: false + t.string "name", null: false + t.string "parent_id" + t.datetime "updated_at", null: false t.index ["family_id"], name: "index_categories_on_family_id" end - create_table "chats", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "user_id", null: false - t.string "title", null: false + create_table "chats", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.json "error" t.string "instructions" - t.jsonb "error" t.string "latest_assistant_response_id" - t.datetime "created_at", null: false + t.string "title", null: false t.datetime "updated_at", null: false + t.string "user_id", null: false t.index ["user_id"], name: "index_chats_on_user_id" end - create_table "coinbase_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "coinbase_item_id", null: false - t.string "name" - t.string "account_id" - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 - t.string "account_status" - t.string "account_type" - t.string "provider" - t.jsonb "institution_metadata" - t.jsonb "raw_payload" - t.jsonb "raw_transactions_payload" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["account_id"], name: "index_coinbase_accounts_on_account_id" - t.index ["coinbase_item_id", "account_id"], name: "index_coinbase_accounts_on_item_and_account_id", unique: true, where: "(account_id IS NOT NULL)" - t.index ["coinbase_item_id"], name: "index_coinbase_accounts_on_coinbase_item_id" - end - - create_table "coinbase_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name" - t.string "institution_id" - t.string "institution_name" - t.string "institution_domain" - t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false - t.boolean "pending_account_setup", default: false - t.datetime "sync_start_date" - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" - t.text "api_key" - t.text "api_secret" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["family_id"], name: "index_coinbase_items_on_family_id" - t.index ["status"], name: "index_coinbase_items_on_status" - end - - create_table "coinstats_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "coinstats_item_id", null: false - t.string "name" - t.string "account_id" - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 - t.string "account_status" - t.string "account_type" - t.string "provider" - t.jsonb "institution_metadata" - t.jsonb "raw_payload" - t.jsonb "raw_transactions_payload" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "wallet_address" - t.index ["coinstats_item_id", "account_id", "wallet_address"], name: "index_coinstats_accounts_on_item_account_and_wallet", unique: true - t.index ["coinstats_item_id"], name: "index_coinstats_accounts_on_coinstats_item_id" - end - - create_table "coinstats_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name" - t.string "institution_id" - t.string "institution_name" - t.string "institution_domain" - t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false - t.boolean "pending_account_setup", default: false - t.datetime "sync_start_date" - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" - t.string "api_key", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "exchange_portfolio_id" - t.string "exchange_connection_id" - t.index ["exchange_connection_id"], name: "index_coinstats_items_on_exchange_connection_id" - t.index ["family_id", "exchange_portfolio_id"], name: "index_coinstats_items_on_family_id_and_exchange_portfolio_id", unique: true, where: "(exchange_portfolio_id IS NOT NULL)" - t.index ["family_id"], name: "index_coinstats_items_on_family_id" - t.index ["status"], name: "index_coinstats_items_on_status" - end - - create_table "credit_cards", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.decimal "available_credit", precision: 10, scale: 2 - t.decimal "minimum_payment", precision: 10, scale: 2 + create_table "credit_cards", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.decimal "annual_fee", precision: 10, scale: 2 t.decimal "apr", precision: 10, scale: 2 + t.decimal "available_credit", precision: 10, scale: 2 + t.datetime "created_at", null: false t.date "expiration_date" - t.decimal "annual_fee", precision: 10, scale: 2 - t.jsonb "locked_attributes", default: {} + t.json "locked_attributes", default: {} + t.decimal "minimum_payment", precision: 10, scale: 2 t.string "subtype" + t.datetime "updated_at", null: false end - create_table "cryptos", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + create_table "cryptos", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.jsonb "locked_attributes", default: {} + t.json "locked_attributes", default: {} t.string "subtype" - t.string "tax_treatment", default: "taxable", null: false + t.datetime "updated_at", null: false end - create_table "data_enrichments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "enrichable_type", null: false - t.uuid "enrichable_id", null: false - t.string "source" + create_table "data_enrichments", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "attribute_name" - t.jsonb "value" - t.jsonb "metadata" t.datetime "created_at", null: false + t.string "enrichable_id", null: false + t.string "enrichable_type", null: false + t.json "metadata" + t.string "source" t.datetime "updated_at", null: false + t.json "value" t.index ["enrichable_id", "enrichable_type", "source", "attribute_name"], name: "idx_on_enrichable_id_enrichable_type_source_attribu_5be5f63e08", unique: true t.index ["enrichable_type", "enrichable_id"], name: "index_data_enrichments_on_enrichable" end - create_table "depositories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + create_table "depositories", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.jsonb "locked_attributes", default: {} + t.json "locked_attributes", default: {} t.string "subtype" - end - - create_table "enable_banking_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "enable_banking_item_id", null: false - t.string "name" - t.string "account_id" - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 - t.string "account_status" - t.string "account_type" - t.string "provider" - t.string "iban" - t.string "uid" - t.jsonb "institution_metadata" - t.jsonb "raw_payload" - t.jsonb "raw_transactions_payload" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "product" - t.decimal "credit_limit", precision: 19, scale: 4 - t.jsonb "identification_hashes", default: [] - t.index ["account_id"], name: "index_enable_banking_accounts_on_account_id" - t.index ["enable_banking_item_id"], name: "index_enable_banking_accounts_on_enable_banking_item_id" - t.index ["identification_hashes"], name: "index_enable_banking_accounts_on_identification_hashes", using: :gin - end - - create_table "enable_banking_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name" - t.string "institution_id" - t.string "institution_name" - t.string "institution_domain" - t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false - t.boolean "pending_account_setup", default: false - t.date "sync_start_date" - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" - t.string "country_code" - t.string "application_id" - t.text "client_certificate" - t.string "session_id" - t.datetime "session_expires_at" - t.string "aspsp_name" - t.string "aspsp_id" - t.string "authorization_id" - t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.jsonb "aspsp_required_psu_headers", default: [] - t.integer "aspsp_maximum_consent_validity" - t.string "aspsp_auth_approach" - t.jsonb "aspsp_psu_types", default: [] - t.string "last_psu_ip" - t.string "psu_type" - t.index ["family_id"], name: "index_enable_banking_items_on_family_id" - t.index ["status"], name: "index_enable_banking_items_on_status" end - create_table "entries", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "account_id", null: false - t.string "entryable_type" - t.uuid "entryable_id" + create_table "entries", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "account_id", null: false t.decimal "amount", precision: 19, scale: 4, null: false + t.datetime "created_at", null: false t.string "currency" t.date "date" + t.string "entryable_id" + t.string "entryable_type" + t.boolean "excluded", default: false + t.string "import_id" + t.json "locked_attributes", default: {} t.string "name", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.uuid "import_id" t.text "notes" - t.boolean "excluded", default: false t.string "plaid_id" - t.jsonb "locked_attributes", default: {} - t.string "external_id" - t.string "source" - t.boolean "user_modified", default: false, null: false - t.boolean "import_locked", default: false, null: false - t.uuid "parent_entry_id" - t.index "lower((name)::text)", name: "index_entries_on_lower_name" + t.datetime "updated_at", null: false t.index ["account_id", "date"], name: "index_entries_on_account_id_and_date" t.index ["account_id", "source", "external_id"], name: "index_entries_on_account_source_and_external_id", unique: true, where: "((external_id IS NOT NULL) AND (source IS NOT NULL))" t.index ["account_id"], name: "index_entries_on_account_id" + t.index ["amount"], name: "index_entries_on_amount" t.index ["date"], name: "index_entries_on_date" + t.index ["entryable_id", "entryable_type"], name: "index_entries_on_entryable" t.index ["entryable_type"], name: "index_entries_on_entryable_type" + t.index ["excluded"], name: "index_entries_on_excluded" t.index ["import_id"], name: "index_entries_on_import_id" - t.index ["import_locked"], name: "index_entries_on_import_locked_true", where: "(import_locked = true)" - t.index ["parent_entry_id"], name: "index_entries_on_parent_entry_id" - t.index ["user_modified"], name: "index_entries_on_user_modified_true", where: "(user_modified = true)" - end - - create_table "eval_datasets", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "name", null: false - t.string "description" - t.string "eval_type", null: false - t.string "version", default: "1.0", null: false - t.integer "sample_count", default: 0 - t.jsonb "metadata", default: {} - t.boolean "active", default: true - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["eval_type", "active"], name: "index_eval_datasets_on_eval_type_and_active" - t.index ["name"], name: "index_eval_datasets_on_name", unique: true + t.index ["name"], name: "index_entries_on_name" end - create_table "eval_results", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "eval_run_id", null: false - t.uuid "eval_sample_id", null: false - t.jsonb "actual_output", null: false - t.boolean "correct", null: false - t.boolean "exact_match", default: false - t.boolean "hierarchical_match", default: false - t.boolean "null_expected", default: false - t.boolean "null_returned", default: false - t.float "fuzzy_score" - t.integer "latency_ms" - t.integer "prompt_tokens" - t.integer "completion_tokens" - t.decimal "cost", precision: 10, scale: 6 - t.jsonb "metadata", default: {} + create_table "exchange_rates", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.boolean "alternative_match", default: false - t.index ["eval_run_id", "correct"], name: "index_eval_results_on_eval_run_id_and_correct" - t.index ["eval_run_id"], name: "index_eval_results_on_eval_run_id" - t.index ["eval_sample_id"], name: "index_eval_results_on_eval_sample_id" - end - - create_table "eval_runs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "eval_dataset_id", null: false - t.string "name" - t.string "status", default: "pending", null: false - t.string "provider", null: false - t.string "model", null: false - t.jsonb "provider_config", default: {} - t.jsonb "metrics", default: {} - t.integer "total_prompt_tokens", default: 0 - t.integer "total_completion_tokens", default: 0 - t.decimal "total_cost", precision: 10, scale: 6, default: "0.0" - t.datetime "started_at" - t.datetime "completed_at" - t.text "error_message" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["eval_dataset_id", "model"], name: "index_eval_runs_on_eval_dataset_id_and_model" - t.index ["eval_dataset_id"], name: "index_eval_runs_on_eval_dataset_id" - t.index ["provider", "model"], name: "index_eval_runs_on_provider_and_model" - t.index ["status"], name: "index_eval_runs_on_status" - end - - create_table "eval_samples", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "eval_dataset_id", null: false - t.jsonb "input_data", null: false - t.jsonb "expected_output", null: false - t.jsonb "context_data", default: {} - t.string "difficulty", default: "medium" - t.string "tags", default: [], array: true - t.jsonb "metadata", default: {} - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["eval_dataset_id", "difficulty"], name: "index_eval_samples_on_eval_dataset_id_and_difficulty" - t.index ["eval_dataset_id"], name: "index_eval_samples_on_eval_dataset_id" - t.index ["tags"], name: "index_eval_samples_on_tags", using: :gin - end - - create_table "exchange_rate_pairs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "from_currency", null: false - t.string "to_currency", null: false - t.date "first_provider_rate_on" - t.string "provider_name" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["from_currency", "to_currency"], name: "index_exchange_rate_pairs_on_pair_unique", unique: true - end - - create_table "exchange_rates", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.date "date", null: false t.string "from_currency", null: false - t.string "to_currency", null: false t.decimal "rate", null: false - t.date "date", null: false - t.datetime "created_at", null: false + t.string "to_currency", null: false t.datetime "updated_at", null: false + t.index ["date", "from_currency", "to_currency"], name: "index_exchange_rates_on_date_and_currencies" t.index ["from_currency", "to_currency", "date"], name: "index_exchange_rates_on_base_converted_date_unique", unique: true t.index ["from_currency"], name: "index_exchange_rates_on_from_currency" t.index ["to_currency"], name: "index_exchange_rates_on_to_currency" end - create_table "families", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "name" + create_table "families", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.boolean "auto_sync_on_login", default: true, null: false + t.string "country", default: "US" t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.string "currency", default: "USD" - t.string "locale", default: "en" - t.string "stripe_customer_id" - t.string "date_format", default: "%m-%d-%Y" - t.string "country", default: "US" - t.string "timezone" t.boolean "data_enrichment_enabled", default: false + t.string "date_format", default: "%m-%d-%Y" t.boolean "early_access", default: false - t.boolean "auto_sync_on_login", default: true, null: false t.datetime "latest_sync_activity_at", default: -> { "CURRENT_TIMESTAMP" } t.datetime "latest_sync_completed_at", default: -> { "CURRENT_TIMESTAMP" } - t.boolean "recurring_transactions_disabled", default: false, null: false - t.integer "month_start_day", default: 1, null: false - t.string "vector_store_id" - t.string "moniker", default: "Family", null: false - t.string "assistant_type", default: "builtin", null: false - t.string "default_account_sharing", default: "shared", null: false - t.string "enabled_currencies", array: true - t.check_constraint "default_account_sharing::text = ANY (ARRAY['shared'::character varying::text, 'private'::character varying::text])", name: "chk_families_default_account_sharing" - t.check_constraint "month_start_day >= 1 AND month_start_day <= 28", name: "month_start_day_range" - end - - create_table "family_documents", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "filename", null: false - t.string "content_type" - t.integer "file_size" - t.string "provider_file_id" - t.string "status", default: "pending", null: false - t.jsonb "metadata", default: {} - t.datetime "created_at", null: false + t.string "locale", default: "en" + t.string "name" + t.string "stripe_customer_id" + t.string "timezone" t.datetime "updated_at", null: false - t.index ["family_id"], name: "index_family_documents_on_family_id" - t.index ["provider_file_id"], name: "index_family_documents_on_provider_file_id" - t.index ["status"], name: "index_family_documents_on_status" end - create_table "family_exports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "status", default: "pending", null: false + create_table "family_exports", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false + t.string "family_id", null: false + t.string "status", default: "pending", null: false t.datetime "updated_at", null: false t.index ["family_id"], name: "index_family_exports_on_family_id" end - create_table "family_merchant_associations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.uuid "merchant_id", null: false - t.datetime "unlinked_at" + create_table "holdings", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "account_id", null: false + t.decimal "amount", precision: 19, scale: 4, null: false t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["family_id", "merchant_id"], name: "idx_on_family_id_merchant_id_23e883e08f", unique: true - t.index ["family_id"], name: "index_family_merchant_associations_on_family_id" - t.index ["merchant_id"], name: "index_family_merchant_associations_on_merchant_id" - end - - create_table "holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "account_id", null: false - t.uuid "security_id", null: false + t.string "currency", null: false t.date "date", null: false - t.decimal "qty", precision: 24, scale: 8, null: false t.decimal "price", precision: 19, scale: 4, null: false - t.decimal "amount", precision: 19, scale: 4, null: false - t.string "currency", null: false - t.datetime "created_at", null: false + t.decimal "qty", precision: 19, scale: 4, null: false + t.string "security_id", null: false t.datetime "updated_at", null: false t.string "external_id" t.decimal "cost_basis", precision: 19, scale: 4 @@ -658,181 +307,113 @@ t.index ["security_id"], name: "index_holdings_on_security_id" end - create_table "impersonation_session_logs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "impersonation_session_id", null: false - t.string "controller" + create_table "impersonation_session_logs", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "action" - t.text "path" - t.string "method" - t.string "ip_address" - t.text "user_agent" + t.string "controller" t.datetime "created_at", null: false + t.string "impersonation_session_id", null: false + t.string "ip_address" + t.string "method" + t.text "path" t.datetime "updated_at", null: false + t.text "user_agent" t.index ["impersonation_session_id"], name: "index_impersonation_session_logs_on_impersonation_session_id" end - create_table "impersonation_sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "impersonator_id", null: false - t.uuid "impersonated_id", null: false - t.string "status", default: "pending", null: false + create_table "impersonation_sessions", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false + t.string "impersonated_id", null: false + t.string "impersonator_id", null: false + t.string "status", default: "pending", null: false t.datetime "updated_at", null: false t.index ["impersonated_id"], name: "index_impersonation_sessions_on_impersonated_id" t.index ["impersonator_id"], name: "index_impersonation_sessions_on_impersonator_id" end - create_table "import_mappings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "type", null: false - t.string "key" - t.string "value" + create_table "import_mappings", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.boolean "create_when_empty", default: true - t.uuid "import_id", null: false - t.string "mappable_type" - t.uuid "mappable_id" t.datetime "created_at", null: false + t.string "import_id", null: false + t.string "key" + t.string "mappable_id" + t.string "mappable_type" + t.string "type", null: false t.datetime "updated_at", null: false + t.string "value" t.index ["import_id"], name: "index_import_mappings_on_import_id" t.index ["mappable_type", "mappable_id"], name: "index_import_mappings_on_mappable" end - create_table "import_rows", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "import_id", null: false + create_table "import_rows", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "account" - t.string "date" - t.string "qty" - t.string "ticker" - t.string "price" t.string "amount" - t.string "currency" - t.string "name" t.string "category" - t.string "tags" + t.datetime "created_at", null: false + t.string "currency" + t.string "date" t.string "entity_type" + t.string "exchange_operating_mic" + t.string "import_id", null: false + t.string "name" t.text "notes" - t.datetime "created_at", null: false + t.string "price" + t.string "qty" + t.string "tags" + t.string "ticker" t.datetime "updated_at", null: false - t.string "exchange_operating_mic" - t.string "category_parent" - t.string "category_color" - t.string "category_classification" - t.string "category_icon" - t.string "resource_type" - t.boolean "active" - t.string "effective_date" - t.text "conditions" - t.text "actions" - t.integer "source_row_number", null: false - t.index ["import_id", "source_row_number"], name: "index_import_rows_on_import_id_and_source_row_number", unique: true t.index ["import_id"], name: "index_import_rows_on_import_id" t.check_constraint "source_row_number > 0", name: "chk_import_rows_source_row_number_positive" end - create_table "imports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.jsonb "column_mappings" - t.string "status" - t.string "raw_file_str" - t.string "normalized_csv_str" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "col_sep", default: "," - t.uuid "family_id", null: false - t.uuid "account_id" - t.string "type", null: false - t.string "date_col_label" + create_table "imports", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "account_col_label" + t.string "account_id" t.string "amount_col_label" - t.string "name_col_label" + t.string "amount_type_inflow_value" + t.string "amount_type_strategy", default: "signed_amount" t.string "category_col_label" - t.string "tags_col_label" - t.string "account_col_label" - t.string "qty_col_label" - t.string "ticker_col_label" - t.string "price_col_label" - t.string "entity_type_col_label" - t.string "notes_col_label" + t.string "col_sep", default: "," + t.json "column_mappings" + t.datetime "created_at", null: false t.string "currency_col_label" + t.string "date_col_label" t.string "date_format", default: "%m/%d/%Y" - t.string "signage_convention", default: "inflows_positive" + t.string "entity_type_col_label" t.string "error" - t.string "number_format" t.string "exchange_operating_mic_col_label" - t.string "amount_type_strategy", default: "signed_amount" - t.string "amount_type_inflow_value" - t.integer "rows_to_skip", default: 0, null: false - t.integer "rows_count", default: 0, null: false - t.string "amount_type_identifier_value" - t.text "ai_summary" - t.string "document_type" - t.jsonb "extracted_data" + t.string "family_id", null: false + t.string "name_col_label" + t.string "normalized_csv_str" + t.string "notes_col_label" + t.string "number_format" + t.string "price_col_label" + t.string "qty_col_label" + t.string "raw_file_str" + t.string "signage_convention", default: "inflows_positive" + t.string "status" + t.string "tags_col_label" + t.string "ticker_col_label" + t.string "type", null: false + t.datetime "updated_at", null: false t.index ["family_id"], name: "index_imports_on_family_id" end - create_table "indexa_capital_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "indexa_capital_item_id", null: false - t.string "name" - t.string "indexa_capital_account_id" - t.string "account_number" - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 - t.string "account_status" - t.string "account_type" - t.string "provider" - t.jsonb "institution_metadata" - t.jsonb "raw_payload" - t.string "indexa_capital_authorization_id" - t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" - t.jsonb "raw_holdings_payload", default: [] - t.jsonb "raw_activities_payload", default: [] - t.datetime "last_holdings_sync" - t.datetime "last_activities_sync" - t.boolean "activities_fetch_pending", default: false - t.date "sync_start_date" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["indexa_capital_authorization_id"], name: "idx_on_indexa_capital_authorization_id_58db208d52" - t.index ["indexa_capital_item_id", "indexa_capital_account_id"], name: "index_indexa_capital_accounts_on_item_and_account_id", unique: true, where: "(indexa_capital_account_id IS NOT NULL)" - t.index ["indexa_capital_item_id"], name: "index_indexa_capital_accounts_on_indexa_capital_item_id" - end - - create_table "indexa_capital_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name" - t.string "institution_id" - t.string "institution_name" - t.string "institution_domain" - t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false - t.boolean "pending_account_setup", default: false - t.datetime "sync_start_date" - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" - t.string "username" - t.string "document" - t.text "password" + create_table "investments", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false + t.json "locked_attributes", default: {} + t.string "subtype" t.datetime "updated_at", null: false - t.text "api_token" - t.index ["family_id"], name: "index_indexa_capital_items_on_family_id" - t.index ["status"], name: "index_indexa_capital_items_on_status" end - create_table "investments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + create_table "invitations", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "accepted_at" t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.jsonb "locked_attributes", default: {} - t.string "subtype" - end - - create_table "invitations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "email" + t.datetime "expires_at" + t.string "family_id", null: false + t.string "inviter_id", null: false t.string "role" t.string "token" - t.uuid "family_id", null: false - t.uuid "inviter_id", null: false - t.datetime "accepted_at" - t.datetime "expires_at" - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "token_digest" t.index ["email", "family_id"], name: "index_invitations_on_email_and_family_id_pending", unique: true, where: "(accepted_at IS NULL)" @@ -843,9 +424,9 @@ t.index ["token_digest"], name: "index_invitations_on_token_digest", unique: true, where: "(token_digest IS NOT NULL)" end - create_table "invite_codes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "token", null: false + create_table "invite_codes", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false + t.string "token", null: false t.datetime "updated_at", null: false t.string "token_digest" t.index ["token"], name: "index_invite_codes_on_token", unique: true @@ -869,171 +450,88 @@ t.index ["family_id"], name: "index_llm_usages_on_family_id" end - create_table "loans", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + create_table "loans", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "rate_type" - t.decimal "interest_rate", precision: 10, scale: 3 - t.integer "term_months" t.decimal "initial_balance", precision: 19, scale: 4 - t.jsonb "locked_attributes", default: {} + t.decimal "interest_rate", precision: 10, scale: 3 + t.json "locked_attributes", default: {} + t.string "rate_type" t.string "subtype" - end - - create_table "lunchflow_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "lunchflow_item_id", null: false - t.string "name" - t.string "account_id" - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 - t.string "account_status" - t.string "provider" - t.string "account_type" - t.jsonb "institution_metadata" - t.jsonb "raw_payload" - t.jsonb "raw_transactions_payload" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.boolean "holdings_supported", default: true, null: false - t.jsonb "raw_holdings_payload" - t.index ["account_id"], name: "index_lunchflow_accounts_on_account_id" - t.index ["lunchflow_item_id", "account_id"], name: "index_lunchflow_accounts_on_item_and_account_id", unique: true, where: "(account_id IS NOT NULL)" - t.index ["lunchflow_item_id"], name: "index_lunchflow_accounts_on_lunchflow_item_id" - end - - create_table "lunchflow_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name" - t.string "institution_id" - t.string "institution_name" - t.string "institution_domain" - t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false - t.boolean "pending_account_setup", default: false - t.datetime "sync_start_date" - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" - t.datetime "created_at", null: false + t.integer "term_months" t.datetime "updated_at", null: false - t.text "api_key" - t.string "base_url" - t.index ["family_id"], name: "index_lunchflow_items_on_family_id" - t.index ["status"], name: "index_lunchflow_items_on_status" end - create_table "merchants", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "name", null: false + create_table "merchants", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "color" - t.uuid "family_id" t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.string "family_id" t.string "logo_url" - t.string "website_url" - t.string "type", null: false - t.string "source" + t.string "name", null: false t.string "provider_merchant_id" - t.index ["family_id", "name"], name: "index_merchants_on_family_id_and_name", unique: true, where: "((type)::text = 'FamilyMerchant'::text)" + t.string "source" + t.string "type", null: false + t.datetime "updated_at", null: false + t.string "website_url" + t.index ["family_id", "name"], name: "index_merchants_on_family_id_and_name" t.index ["family_id"], name: "index_merchants_on_family_id" - t.index ["provider_merchant_id", "source"], name: "index_merchants_on_provider_merchant_id_and_source", unique: true, where: "((provider_merchant_id IS NOT NULL) AND ((type)::text = 'ProviderMerchant'::text))" - t.index ["source", "name"], name: "index_merchants_on_source_and_name", unique: true, where: "((type)::text = 'ProviderMerchant'::text)" + t.index ["source", "name"], name: "index_merchants_on_source_and_name" t.index ["type"], name: "index_merchants_on_type" end - create_table "mercury_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "mercury_item_id", null: false - t.string "name" - t.string "account_id", null: false - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 - t.string "account_status" - t.string "account_type" - t.string "provider" - t.jsonb "institution_metadata" - t.jsonb "raw_payload" - t.jsonb "raw_transactions_payload" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["mercury_item_id", "account_id"], name: "index_mercury_accounts_on_item_and_account_id", unique: true - t.index ["mercury_item_id"], name: "index_mercury_accounts_on_mercury_item_id" - end - - create_table "mercury_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name" - t.string "institution_id" - t.string "institution_name" - t.string "institution_domain" - t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false - t.boolean "pending_account_setup", default: false - t.datetime "sync_start_date" - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" - t.text "token" - t.string "base_url" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["family_id"], name: "index_mercury_items_on_family_id" - t.index ["status"], name: "index_mercury_items_on_status" - end - - create_table "messages", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "chat_id", null: false - t.string "type", null: false - t.string "status", default: "complete", null: false - t.text "content" + create_table "messages", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "ai_model" + t.string "chat_id", null: false + t.text "content" t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.boolean "debug", default: false t.string "provider_id" t.boolean "reasoning", default: false + t.string "status", default: "complete", null: false + t.string "type", null: false + t.datetime "updated_at", null: false t.index ["chat_id"], name: "index_messages_on_chat_id" end - create_table "mobile_devices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "user_id", null: false + create_table "mobile_devices", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "app_version" + t.datetime "created_at", null: false t.string "device_id" t.string "device_name" t.string "device_type" - t.string "os_version" - t.string "app_version" t.datetime "last_seen_at" - t.datetime "created_at", null: false + t.integer "oauth_application_id" + t.string "os_version" t.datetime "updated_at", null: false + t.string "user_id", null: false + t.index ["oauth_application_id"], name: "index_mobile_devices_on_oauth_application_id" t.index ["user_id", "device_id"], name: "index_mobile_devices_on_user_id_and_device_id", unique: true t.index ["user_id"], name: "index_mobile_devices_on_user_id" end create_table "oauth_access_grants", force: :cascade do |t| - t.string "resource_owner_id", null: false t.bigint "application_id", null: false - t.string "token", null: false + t.datetime "created_at", null: false t.integer "expires_in", null: false t.text "redirect_uri", null: false - t.string "scopes", default: "", null: false - t.datetime "created_at", null: false + t.string "resource_owner_id", null: false t.datetime "revoked_at" + t.string "scopes", default: "", null: false + t.string "token", null: false t.index ["application_id"], name: "index_oauth_access_grants_on_application_id" t.index ["resource_owner_id"], name: "index_oauth_access_grants_on_resource_owner_id" t.index ["token"], name: "index_oauth_access_grants_on_token", unique: true end create_table "oauth_access_tokens", force: :cascade do |t| - t.string "resource_owner_id" t.bigint "application_id", null: false - t.string "token", null: false - t.string "refresh_token" - t.integer "expires_in" - t.string "scopes" t.datetime "created_at", null: false - t.datetime "revoked_at" + t.integer "expires_in" t.string "previous_refresh_token", default: "", null: false - t.uuid "mobile_device_id" + t.string "refresh_token" + t.string "resource_owner_id" + t.datetime "revoked_at" + t.string "scopes" + t.string "token", null: false t.index ["application_id"], name: "index_oauth_access_tokens_on_application_id" t.index ["mobile_device_id"], name: "index_oauth_access_tokens_on_mobile_device_id" t.index ["refresh_token"], name: "index_oauth_access_tokens_on_refresh_token", unique: true @@ -1042,290 +540,216 @@ end create_table "oauth_applications", force: :cascade do |t| + t.boolean "confidential", default: true, null: false + t.datetime "created_at", null: false t.string "name", null: false - t.string "uid", null: false - t.string "secret", null: false + t.string "owner_id" + t.string "owner_type" t.text "redirect_uri", null: false t.string "scopes", default: "", null: false - t.boolean "confidential", default: true, null: false - t.datetime "created_at", null: false + t.string "secret", null: false + t.string "uid", null: false t.datetime "updated_at", null: false - t.uuid "owner_id" - t.string "owner_type" t.index ["owner_id", "owner_type"], name: "index_oauth_applications_on_owner_id_and_owner_type" t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true end - create_table "oidc_identities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "user_id", null: false - t.string "provider", null: false - t.string "uid", null: false - t.jsonb "info", default: {} - t.datetime "last_authenticated_at" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "issuer" - t.index ["issuer"], name: "index_oidc_identities_on_issuer" - t.index ["provider", "uid"], name: "index_oidc_identities_on_provider_and_uid", unique: true - t.index ["user_id"], name: "index_oidc_identities_on_user_id" - end - - create_table "other_assets", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + create_table "other_assets", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.jsonb "locked_attributes", default: {} + t.json "locked_attributes", default: {} t.string "subtype" + t.datetime "updated_at", null: false end - create_table "other_liabilities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + create_table "other_liabilities", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.jsonb "locked_attributes", default: {} + t.json "locked_attributes", default: {} t.string "subtype" + t.datetime "updated_at", null: false end - create_table "plaid_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "plaid_item_id", null: false - t.string "plaid_id", null: false - t.string "plaid_type", null: false - t.string "plaid_subtype" - t.decimal "current_balance", precision: 19, scale: 4 + create_table "plaid_accounts", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.decimal "available_balance", precision: 19, scale: 4 + t.datetime "created_at", null: false t.string "currency", null: false - t.string "name", null: false + t.decimal "current_balance", precision: 19, scale: 4 t.string "mask" - t.datetime "created_at", null: false + t.string "name", null: false + t.string "plaid_id", null: false + t.string "plaid_item_id", null: false + t.string "plaid_subtype" + t.string "plaid_type", null: false + t.json "raw_investments_payload", default: {} + t.json "raw_liabilities_payload", default: {} + t.json "raw_payload", default: {} + t.json "raw_transactions_payload", default: {} t.datetime "updated_at", null: false - t.jsonb "raw_payload", default: {} - t.jsonb "raw_transactions_payload", default: {} - t.jsonb "raw_holdings_payload", default: {} - t.jsonb "raw_liabilities_payload", default: {} - t.index ["plaid_item_id", "plaid_id"], name: "index_plaid_accounts_on_item_and_plaid_id", unique: true + t.index ["plaid_id"], name: "index_plaid_accounts_on_plaid_id", unique: true t.index ["plaid_item_id"], name: "index_plaid_accounts_on_plaid_item_id" end - create_table "plaid_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false + create_table "plaid_items", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "access_token" - t.string "plaid_id", null: false + t.json "available_products", default: [] + t.json "billed_products", default: [] + t.datetime "created_at", null: false + t.string "family_id", null: false + t.string "institution_color" + t.string "institution_id" + t.string "institution_url" t.string "name" t.string "next_cursor" - t.boolean "scheduled_for_deletion", default: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "available_products", default: [], array: true - t.string "billed_products", default: [], array: true + t.string "plaid_id", null: false t.string "plaid_region", default: "us", null: false - t.string "institution_url" - t.string "institution_id" - t.string "institution_color" + t.json "raw_institution_payload", default: {} + t.json "raw_payload", default: {} + t.boolean "scheduled_for_deletion", default: false t.string "status", default: "good", null: false - t.jsonb "raw_payload", default: {} - t.jsonb "raw_institution_payload", default: {} + t.datetime "updated_at", null: false t.index ["family_id"], name: "index_plaid_items_on_family_id" t.index ["plaid_id"], name: "index_plaid_items_on_plaid_id", unique: true end - create_table "properties", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.integer "year_built" - t.integer "area_value" + create_table "properties", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "area_unit" - t.jsonb "locked_attributes", default: {} - t.string "subtype" - end - - create_table "recurring_transactions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.uuid "merchant_id" - t.decimal "amount", precision: 19, scale: 4, null: false - t.string "currency", null: false - t.integer "expected_day_of_month", null: false - t.date "last_occurrence_date", null: false - t.date "next_expected_date", null: false - t.string "status", default: "active", null: false - t.integer "occurrence_count", default: 0, null: false + t.integer "area_value" t.datetime "created_at", null: false + t.json "locked_attributes", default: {} + t.string "subtype" t.datetime "updated_at", null: false - t.string "name" - t.boolean "manual", default: false, null: false - t.decimal "expected_amount_min", precision: 19, scale: 4 - t.decimal "expected_amount_max", precision: 19, scale: 4 - t.decimal "expected_amount_avg", precision: 19, scale: 4 - t.uuid "account_id" - t.index ["account_id"], name: "index_recurring_transactions_on_account_id" - t.index ["family_id", "account_id", "merchant_id", "amount", "currency"], name: "idx_recurring_txns_acct_merchant", unique: true, where: "(merchant_id IS NOT NULL)" - t.index ["family_id", "account_id", "name", "amount", "currency"], name: "idx_recurring_txns_acct_name", unique: true, where: "((name IS NOT NULL) AND (merchant_id IS NULL))" - t.index ["family_id", "status"], name: "index_recurring_transactions_on_family_id_and_status" - t.index ["family_id"], name: "index_recurring_transactions_on_family_id" - t.index ["merchant_id"], name: "index_recurring_transactions_on_merchant_id" - t.index ["next_expected_date"], name: "index_recurring_transactions_on_next_expected_date" + t.integer "year_built" end - create_table "rejected_transfers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "inflow_transaction_id", null: false - t.uuid "outflow_transaction_id", null: false + create_table "rejected_transfers", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false + t.string "inflow_transaction_id", null: false + t.string "outflow_transaction_id", null: false t.datetime "updated_at", null: false t.index ["inflow_transaction_id", "outflow_transaction_id"], name: "idx_on_inflow_transaction_id_outflow_transaction_id_412f8e7e26", unique: true t.index ["inflow_transaction_id"], name: "index_rejected_transfers_on_inflow_transaction_id" t.index ["outflow_transaction_id"], name: "index_rejected_transfers_on_outflow_transaction_id" end - create_table "rule_actions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "rule_id", null: false + create_table "rule_actions", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "action_type", null: false - t.string "value" t.datetime "created_at", null: false + t.string "rule_id", null: false t.datetime "updated_at", null: false + t.string "value" t.index ["rule_id"], name: "index_rule_actions_on_rule_id" end - create_table "rule_conditions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "rule_id" - t.uuid "parent_id" + create_table "rule_conditions", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "condition_type", null: false - t.string "operator", null: false - t.string "value" t.datetime "created_at", null: false + t.string "operator", null: false + t.string "parent_id" + t.string "rule_id" t.datetime "updated_at", null: false + t.string "value" t.index ["parent_id"], name: "index_rule_conditions_on_parent_id" t.index ["rule_id"], name: "index_rule_conditions_on_rule_id" end - create_table "rule_runs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "rule_id", null: false - t.string "rule_name" - t.string "execution_type", null: false - t.string "status", null: false - t.integer "transactions_queued", default: 0, null: false - t.integer "transactions_processed", default: 0, null: false - t.integer "transactions_modified", default: 0, null: false - t.integer "pending_jobs_count", default: 0, null: false - t.datetime "executed_at", null: false - t.text "error_message" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["executed_at"], name: "index_rule_runs_on_executed_at" - t.index ["rule_id", "executed_at"], name: "index_rule_runs_on_rule_id_and_executed_at" - t.index ["rule_id"], name: "index_rule_runs_on_rule_id" - end - - create_table "rules", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "resource_type", null: false - t.date "effective_date" + create_table "rules", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.boolean "active", default: false, null: false t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.date "effective_date" + t.string "family_id", null: false t.string "name" + t.string "resource_type", null: false + t.datetime "updated_at", null: false t.index ["family_id"], name: "index_rules_on_family_id" end - create_table "securities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "ticker", null: false - t.string "name" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + create_table "securities", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "country_code" - t.string "exchange_mic" + t.datetime "created_at", null: false t.string "exchange_acronym" - t.string "logo_url" + t.string "exchange_mic" t.string "exchange_operating_mic" - t.boolean "offline", default: false, null: false t.datetime "failed_fetch_at" t.integer "failed_fetch_count", default: 0, null: false t.datetime "last_health_check_at" - t.string "website_url" - t.string "kind", default: "standard", null: false - t.string "price_provider" - t.string "offline_reason" - t.date "first_provider_price_on" - t.index "upper((ticker)::text), COALESCE(upper((exchange_operating_mic)::text), ''::text)", name: "index_securities_on_ticker_and_exchange_operating_mic_unique", unique: true + t.string "logo_url" + t.string "name" + t.boolean "offline", default: false, null: false + t.string "ticker", null: false + t.datetime "updated_at", null: false t.index ["country_code"], name: "index_securities_on_country_code" t.index ["exchange_operating_mic"], name: "index_securities_on_exchange_operating_mic" - t.index ["kind"], name: "index_securities_on_kind" - t.index ["price_provider", "offline_reason"], name: "index_securities_on_price_provider_and_offline_reason" - t.index ["price_provider"], name: "index_securities_on_price_provider" - t.check_constraint "kind::text = ANY (ARRAY['standard'::character varying, 'cash'::character varying]::text[])", name: "chk_securities_kind" + t.index ["ticker", "exchange_operating_mic"], name: "index_securities_on_ticker_and_exchange_operating_mic_unique", unique: true end - create_table "security_prices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + create_table "security_prices", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.string "currency", default: "USD", null: false t.date "date", null: false t.decimal "price", precision: 19, scale: 4, null: false - t.string "currency", default: "USD", null: false - t.datetime "created_at", null: false + t.string "security_id" t.datetime "updated_at", null: false - t.uuid "security_id" - t.boolean "provisional", default: false, null: false t.index ["security_id", "date", "currency"], name: "index_security_prices_on_security_id_and_date_and_currency", unique: true t.index ["security_id"], name: "index_security_prices_on_security_id" end - create_table "sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "user_id", null: false - t.string "user_agent" - t.string "ip_address" + create_table "sessions", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "active_impersonator_session_id" t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.uuid "active_impersonator_session_id" + t.json "data", default: {} + t.string "ip_address" + t.json "prev_transaction_page_params", default: {} t.datetime "subscribed_at" - t.jsonb "prev_transaction_page_params", default: {} - t.jsonb "data", default: {} - t.string "ip_address_digest" + t.datetime "updated_at", null: false + t.string "user_agent" + t.string "user_id", null: false t.index ["active_impersonator_session_id"], name: "index_sessions_on_active_impersonator_session_id" t.index ["ip_address_digest"], name: "index_sessions_on_ip_address_digest" t.index ["user_id"], name: "index_sessions_on_user_id" end create_table "settings", force: :cascade do |t| - t.string "var", null: false - t.text "value" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.text "value" + t.string "var", null: false t.index ["var"], name: "index_settings_on_var", unique: true end - create_table "simplefin_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "simplefin_item_id", null: false - t.string "name" + create_table "simplefin_accounts", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "account_id" - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 - t.decimal "available_balance", precision: 19, scale: 4 - t.string "account_type" t.string "account_subtype" - t.jsonb "raw_payload" - t.jsonb "raw_transactions_payload" + t.string "account_type" + t.decimal "available_balance", precision: 19, scale: 4 + t.datetime "balance_date" t.datetime "created_at", null: false + t.string "currency" + t.decimal "current_balance", precision: 19, scale: 4 + t.json "extra" + t.string "name" + t.json "org_data" + t.json "raw_payload" + t.json "raw_transactions_payload" + t.string "simplefin_item_id", null: false t.datetime "updated_at", null: false - t.datetime "balance_date" - t.jsonb "extra" - t.jsonb "org_data" - t.jsonb "raw_holdings_payload" t.index ["account_id"], name: "index_simplefin_accounts_on_account_id" t.index ["simplefin_item_id", "account_id"], name: "idx_unique_sfa_per_item_and_upstream", unique: true, where: "(account_id IS NOT NULL)" t.index ["simplefin_item_id"], name: "index_simplefin_accounts_on_simplefin_item_id" end - create_table "simplefin_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false + create_table "simplefin_items", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.text "access_url" - t.string "name" + t.datetime "created_at", null: false + t.string "family_id", null: false t.string "institution_id" t.string "institution_name" t.string "institution_url" - t.string "status", default: "good" + t.string "name" + t.boolean "pending_account_setup", default: false, null: false + t.json "raw_institution_payload" + t.json "raw_payload" t.boolean "scheduled_for_deletion", default: false - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" - t.datetime "created_at", null: false + t.string "status", default: "good" t.datetime "updated_at", null: false - t.boolean "pending_account_setup", default: false, null: false - t.string "institution_domain" - t.string "institution_color" - t.date "sync_start_date" t.index ["family_id"], name: "index_simplefin_items_on_family_id" t.index ["institution_domain"], name: "index_simplefin_items_on_institution_domain" t.index ["institution_id"], name: "index_simplefin_items_on_institution_id" @@ -1333,229 +757,92 @@ t.index ["status"], name: "index_simplefin_items_on_status" end - create_table "snaptrade_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "snaptrade_item_id", null: false - t.string "name" - t.string "snaptrade_account_id" - t.string "snaptrade_authorization_id" - t.string "account_number" - t.string "brokerage_name" - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 - t.decimal "cash_balance", precision: 19, scale: 4 - t.string "account_status" - t.string "account_type" - t.string "provider" - t.jsonb "institution_metadata" - t.jsonb "raw_payload" - t.jsonb "raw_transactions_payload" - t.jsonb "raw_holdings_payload", default: [] - t.jsonb "raw_activities_payload", default: [] - t.datetime "last_holdings_sync" - t.datetime "last_activities_sync" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.boolean "activities_fetch_pending", default: false - t.date "sync_start_date" - t.index ["snaptrade_item_id", "snaptrade_account_id"], name: "index_snaptrade_accounts_on_item_and_snaptrade_account_id", unique: true, where: "(snaptrade_account_id IS NOT NULL)" - t.index ["snaptrade_item_id"], name: "index_snaptrade_accounts_on_snaptrade_item_id" - end - - create_table "snaptrade_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name" - t.string "institution_id" - t.string "institution_name" - t.string "institution_domain" - t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false - t.boolean "pending_account_setup", default: false - t.datetime "sync_start_date" - t.datetime "last_synced_at" - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" - t.string "client_id" - t.string "consumer_key" - t.string "snaptrade_user_id" - t.string "snaptrade_user_secret" + create_table "subscriptions", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.decimal "amount", precision: 19, scale: 4 t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["family_id"], name: "index_snaptrade_items_on_family_id" - t.index ["status"], name: "index_snaptrade_items_on_status" - end - - create_table "sophtron_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "sophtron_item_id", null: false - t.string "name", null: false - t.string "account_id", null: false t.string "currency" - t.decimal "balance", precision: 19, scale: 4 - t.decimal "available_balance", precision: 19, scale: 4 - t.string "account_status" - t.string "account_type" - t.string "account_sub_type" - t.datetime "last_updated" - t.jsonb "institution_metadata" - t.jsonb "raw_payload" - t.jsonb "raw_transactions_payload" - t.string "customer_id", null: false - t.string "member_id", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["account_id"], name: "index_sophtron_accounts_on_account_id" - t.index ["sophtron_item_id"], name: "index_sophtron_accounts_on_sophtron_item_id" - t.index ["sophtron_item_id", "account_id"], name: "idx_unique_sophtron_accounts_per_item", unique: true - end - - create_table "sophtron_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name" - t.string "institution_id" - t.string "institution_name" - t.string "institution_domain" - t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false - t.boolean "pending_account_setup", default: false - t.datetime "sync_start_date" - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" - t.string "user_id", null: false - t.string "access_key", null: false - t.string "base_url" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["family_id"], name: "index_sophtron_items_on_family_id" - t.index ["status"], name: "index_sophtron_items_on_status" - end - - create_table "sso_audit_logs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "user_id" - t.string "event_type", null: false - t.string "provider" - t.string "ip_address" - t.string "user_agent" - t.jsonb "metadata", default: {}, null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["created_at"], name: "index_sso_audit_logs_on_created_at" - t.index ["event_type"], name: "index_sso_audit_logs_on_event_type" - t.index ["user_id", "created_at"], name: "index_sso_audit_logs_on_user_id_and_created_at" - t.index ["user_id"], name: "index_sso_audit_logs_on_user_id" - end - - create_table "sso_providers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "strategy", null: false - t.string "name", null: false - t.string "label", null: false - t.string "icon" - t.boolean "enabled", default: true, null: false - t.string "issuer" - t.string "client_id" - t.string "client_secret" - t.string "redirect_uri" - t.jsonb "settings", default: {}, null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["enabled"], name: "index_sso_providers_on_enabled" - t.index ["name"], name: "index_sso_providers_on_name", unique: true - end - - create_table "subscriptions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false + t.datetime "current_period_ends_at" + t.string "family_id", null: false + t.string "interval" t.string "status", null: false t.string "stripe_id" - t.decimal "amount", precision: 19, scale: 4 - t.string "currency" - t.string "interval" - t.datetime "current_period_ends_at" t.datetime "trial_ends_at" - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.boolean "cancel_at_period_end", default: false, null: false t.index ["family_id"], name: "index_subscriptions_on_family_id", unique: true end - create_table "syncs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "syncable_type", null: false - t.uuid "syncable_id", null: false - t.string "status", default: "pending" - t.string "error" - t.jsonb "data" + create_table "syncs", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "completed_at" t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.uuid "parent_id" + t.json "data" + t.string "error" + t.datetime "failed_at" + t.string "parent_id" t.datetime "pending_at" + t.string "status", default: "pending" + t.string "syncable_id", null: false + t.string "syncable_type", null: false t.datetime "syncing_at" - t.datetime "completed_at" - t.datetime "failed_at" - t.date "window_start_date" + t.datetime "updated_at", null: false t.date "window_end_date" - t.text "sync_stats" + t.date "window_start_date" t.index ["parent_id"], name: "index_syncs_on_parent_id" t.index ["status"], name: "index_syncs_on_status" t.index ["syncable_type", "syncable_id"], name: "index_syncs_on_syncable" end - create_table "taggings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "tag_id", null: false - t.string "taggable_type" - t.uuid "taggable_id" + create_table "taggings", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false + t.string "tag_id", null: false + t.string "taggable_id" + t.string "taggable_type" t.datetime "updated_at", null: false t.index ["tag_id"], name: "index_taggings_on_tag_id" t.index ["taggable_type", "taggable_id"], name: "index_taggings_on_taggable" end - create_table "tags", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "name" + create_table "tags", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "color", default: "#e99537", null: false - t.uuid "family_id", null: false t.datetime "created_at", null: false + t.string "family_id", null: false + t.string "name" t.datetime "updated_at", null: false t.index ["family_id"], name: "index_tags_on_family_id" end - create_table "tool_calls", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "message_id", null: false - t.string "provider_id", null: false + create_table "tool_calls", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.json "function_arguments" + t.string "function_name" + t.json "function_result" + t.string "message_id", null: false t.string "provider_call_id" + t.string "provider_id", null: false t.string "type", null: false - t.string "function_name" - t.jsonb "function_arguments" - t.jsonb "function_result" - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["message_id"], name: "index_tool_calls_on_message_id" end - create_table "trades", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "security_id", null: false - t.decimal "qty", precision: 24, scale: 8 - t.decimal "price", precision: 19, scale: 10 + create_table "trades", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.string "currency" - t.jsonb "locked_attributes", default: {} - t.string "investment_activity_label" - t.decimal "fee", precision: 19, scale: 4, default: "0.0", null: false - t.index ["investment_activity_label"], name: "index_trades_on_investment_activity_label" + t.json "locked_attributes", default: {} + t.decimal "price", precision: 19, scale: 4 + t.decimal "qty", precision: 19, scale: 4 + t.string "security_id", null: false + t.datetime "updated_at", null: false t.index ["security_id"], name: "index_trades_on_security_id" end - create_table "transactions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + create_table "transactions", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "category_id" t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.uuid "category_id" - t.uuid "merchant_id" - t.jsonb "locked_attributes", default: {} - t.string "kind", default: "standard", null: false t.string "external_id" - t.jsonb "extra", default: {}, null: false - t.string "investment_activity_label" + t.string "kind", default: "standard", null: false + t.json "locked_attributes", default: {} + t.string "merchant_id" + t.datetime "updated_at", null: false t.index ["category_id"], name: "index_transactions_on_category_id" t.index ["external_id"], name: "index_transactions_on_external_id" t.index ["extra"], name: "index_transactions_on_extra", using: :gin @@ -1564,12 +851,12 @@ t.index ["merchant_id"], name: "index_transactions_on_merchant_id" end - create_table "transfers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "inflow_transaction_id", null: false - t.uuid "outflow_transaction_id", null: false - t.string "status", default: "pending", null: false - t.text "notes" + create_table "transfers", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false + t.string "inflow_transaction_id", null: false + t.text "notes" + t.string "outflow_transaction_id", null: false + t.string "status", default: "pending", null: false t.datetime "updated_at", null: false t.index ["inflow_transaction_id", "outflow_transaction_id"], name: "idx_on_inflow_transaction_id_outflow_transaction_id_8cd07a28bd", unique: true t.index ["inflow_transaction_id"], name: "index_transfers_on_inflow_transaction_id" @@ -1577,65 +864,56 @@ t.index ["status"], name: "index_transfers_on_status" end - create_table "users", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false + create_table "users", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.boolean "active", default: true, null: false + t.boolean "ai_enabled", default: false, null: false + t.datetime "created_at", null: false + t.string "default_account_order", default: "name_asc" + t.string "default_period", default: "last_30_days", null: false + t.string "email" + t.string "family_id", null: false t.string "first_name" + t.json "goals", default: [] t.string "last_name" - t.string "email" - t.string "password_digest" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "role", default: "member", null: false - t.boolean "active", default: true, null: false + t.string "last_viewed_chat_id" t.datetime "onboarded_at" - t.string "unconfirmed_email" - t.string "otp_secret" + t.json "otp_backup_codes", default: [] t.boolean "otp_required", default: false, null: false - t.string "otp_backup_codes", default: [], array: true - t.boolean "show_sidebar", default: true - t.string "default_period", default: "last_30_days", null: false - t.uuid "last_viewed_chat_id" - t.boolean "show_ai_sidebar", default: true - t.boolean "ai_enabled", default: false, null: false - t.string "theme", default: "system" - t.boolean "rule_prompts_disabled", default: false + t.string "otp_secret" + t.string "password_digest" + t.string "role", default: "member", null: false t.datetime "rule_prompt_dismissed_at" - t.text "goals", default: [], array: true - t.datetime "set_onboarding_preferences_at" + t.boolean "rule_prompts_disabled", default: false t.datetime "set_onboarding_goals_at" - t.string "default_account_order", default: "name_asc" - t.jsonb "preferences", default: {}, null: false - t.string "locale" - t.string "ui_layout" - t.uuid "default_account_id" - t.string "webauthn_id" - t.index ["default_account_id"], name: "index_users_on_default_account_id" + t.datetime "set_onboarding_preferences_at" + t.boolean "show_ai_sidebar", default: true + t.boolean "show_sidebar", default: true + t.string "theme", default: "system" + t.string "unconfirmed_email" + t.datetime "updated_at", null: false t.index ["email"], name: "index_users_on_email", unique: true t.index ["family_id"], name: "index_users_on_family_id" t.index ["last_viewed_chat_id"], name: "index_users_on_last_viewed_chat_id" - t.index ["locale"], name: "index_users_on_locale" - t.index ["otp_secret"], name: "index_users_on_otp_secret", unique: true, where: "(otp_secret IS NOT NULL)" - t.index ["preferences"], name: "index_users_on_preferences", using: :gin - t.index ["webauthn_id"], name: "index_users_on_webauthn_id", unique: true, where: "(webauthn_id IS NOT NULL)" + t.index ["otp_secret"], name: "index_users_on_otp_secret" end - create_table "valuations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + create_table "valuations", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.jsonb "locked_attributes", default: {} t.string "kind", default: "reconciliation", null: false + t.json "locked_attributes", default: {} + t.datetime "updated_at", null: false end - create_table "vehicles", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + create_table "vehicles", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.integer "year" - t.integer "mileage_value" - t.string "mileage_unit" + t.json "locked_attributes", default: {} t.string "make" + t.string "mileage_unit" + t.integer "mileage_value" t.string "model" - t.jsonb "locked_attributes", default: {} t.string "subtype" + t.datetime "updated_at", null: false + t.integer "year" end create_table "webauthn_credentials", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| diff --git a/db/seeds.rb b/db/seeds.rb index 3de4ad5e2a3..50a6c482cc3 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -4,7 +4,9 @@ puts 'Run the following command to create demo data: `rake demo_data:default`' if Rails.env.development? -Dir[Rails.root.join('db', 'seeds', '*.rb')].sort.each do |file| - puts "Loading seed file: #{File.basename(file)}" - require file +ActiveRecord::Base.transaction do + Dir[Rails.root.join('db', 'seeds', '*.rb')].sort.each do |file| + puts "Loading seed file: #{File.basename(file)}" + require file + end end diff --git a/mise.toml b/mise.toml new file mode 100644 index 00000000000..c6aa6a31ed3 --- /dev/null +++ b/mise.toml @@ -0,0 +1,3 @@ +[tools] +# Keep these pins aligned with .ruby-version +ruby = "3.4.6" From 83912daba1e89090bee8f46e0270cf95cd806c24 Mon Sep 17 00:00:00 2001 From: Tin Tran Date: Tue, 5 May 2026 23:10:44 +0700 Subject: [PATCH 2/2] Carry fork domain portability changes --- .../balance_component_migrator.rb | 68 +++--- app/jobs/import_market_data_job.rb | 4 +- app/jobs/sync_job.rb | 1 + app/models/account.rb | 81 ++----- app/models/account/chartable.rb | 6 +- app/models/account/syncer.rb | 2 +- app/models/concerns/enrichable.rb | 6 +- app/models/concerns/syncable.rb | 2 +- app/models/entry_search.rb | 8 +- app/models/family/auto_transfer_matchable.rb | 6 +- app/models/holding.rb | 25 ++- app/models/holding/portfolio_snapshot.rb | 7 +- app/models/income_statement/category_stats.rb | 153 +++++++------ app/models/income_statement/family_stats.rb | 139 ++++++------ app/models/income_statement/totals.rb | 203 +++++------------- app/models/mint_import.rb | 1 - app/models/mobile_device.rb | 33 ++- app/models/plaid_item.rb | 3 +- app/models/plaid_item/webhook_processor.rb | 2 +- app/models/provider/stripe.rb | 2 +- app/models/rule/condition_filter.rb | 7 +- app/models/trade_import.rb | 7 +- app/models/transaction.rb | 179 +-------------- app/models/transaction_import.rb | 71 ++---- app/models/transfer.rb | 2 +- test/controllers/accounts_controller_test.rb | 70 ++---- .../balance_component_migrator_test.rb | 3 + test/models/account_test.rb | 180 ++-------------- test/models/holding_test.rb | 29 +-- test/models/transfer/creator_test.rb | 5 +- test/system/trades_test.rb | 8 +- test/system/transfers_test.rb | 4 + test/test_helper.rb | 5 +- 33 files changed, 415 insertions(+), 907 deletions(-) diff --git a/app/data_migrations/balance_component_migrator.rb b/app/data_migrations/balance_component_migrator.rb index 806c2abc744..d1c9d1d0c3b 100644 --- a/app/data_migrations/balance_component_migrator.rb +++ b/app/data_migrations/balance_component_migrator.rb @@ -2,54 +2,46 @@ class BalanceComponentMigrator def self.run ActiveRecord::Base.transaction do # Step 1: Update flows factor - ActiveRecord::Base.connection.execute <<~SQL - UPDATE balances SET - flows_factor = CASE WHEN a.classification = 'asset' THEN 1 ELSE -1 END - FROM accounts a - WHERE a.id = balances.account_id - SQL + Balance.all.each do |balance| + balance.flows_factor = balance.account.classification == "asset" ? 1 : -1 + balance.save! + end # Step 2: Set start values using LOCF (Last Observation Carried Forward) - ActiveRecord::Base.connection.execute <<~SQL - UPDATE balances b1 - SET - start_cash_balance = COALESCE(prev.cash_balance, 0), - start_non_cash_balance = COALESCE(prev.balance - prev.cash_balance, 0) - FROM balances b1_inner - LEFT JOIN LATERAL ( - SELECT - b2.cash_balance, - b2.balance - FROM balances b2 - WHERE b2.account_id = b1_inner.account_id - AND b2.currency = b1_inner.currency - AND b2.date < b1_inner.date - ORDER BY b2.date DESC - LIMIT 1 - ) prev ON true - WHERE b1.id = b1_inner.id - SQL + Balance.all.each do |balance| + prev = Balance + .where(account_id: balance.account_id, currency: balance.currency) + .where("date < ?", balance.date).order(date: :desc) + .first + if prev + balance.start_cash_balance = prev.cash_balance + balance.start_non_cash_balance = prev.balance - prev.cash_balance + else + balance.start_cash_balance = 0 + balance.start_non_cash_balance = 0 + end + balance.save! + end # Step 3: Calculate net inflows # A slight workaround to the fact that we can't easily derive inflows/outflows from our current data model, and # the tradeoff not worth it since each new sync will fix it. So instead, we sum up *net* flows, and throw the signed # amount in the "inflows" column, and zero-out the "outflows" column so our math works correctly with incomplete data. - ActiveRecord::Base.connection.execute <<~SQL - UPDATE balances SET - cash_inflows = (cash_balance - start_cash_balance) * flows_factor, - cash_outflows = 0, - non_cash_inflows = ((balance - cash_balance) - start_non_cash_balance) * flows_factor, - non_cash_outflows = 0, - net_market_flows = 0 - SQL + Balance.all.each do |balance| + balance.cash_inflows = (balance.cash_balance - balance.start_cash_balance) * balance.flows_factor + balance.cash_outflows = 0 + balance.non_cash_inflows = ((balance.balance - balance.cash_balance) - balance.start_non_cash_balance) * balance.flows_factor + balance.non_cash_outflows = 0 + balance.net_market_flows = 0 + balance.save! + end # Verify data integrity # All end_balance values should match the original balance - invalid_count = ActiveRecord::Base.connection.select_value(<<~SQL) - SELECT COUNT(*) - FROM balances b - WHERE ABS(b.balance - b.end_balance) > 0.0001 - SQL + invalid_balances = Balance.all.filter do |balance| + (balance.balance - balance.end_balance).abs > 0.0001 + end + invalid_count = invalid_balances.count if invalid_count > 0 raise "Data migration failed validation: #{invalid_count} balances have incorrect end_balance values" diff --git a/app/jobs/import_market_data_job.rb b/app/jobs/import_market_data_job.rb index 7d146c68ca4..6de44e8ca2a 100644 --- a/app/jobs/import_market_data_job.rb +++ b/app/jobs/import_market_data_job.rb @@ -10,10 +10,10 @@ class ImportMarketDataJob < ApplicationJob queue_as :scheduled - def perform(opts) + def perform(opts = {}) return if Rails.env.development? - opts = opts.symbolize_keys + opts = (opts || {}).symbolize_keys mode = opts.fetch(:mode, :full) clear_cache = opts.fetch(:clear_cache, false) diff --git a/app/jobs/sync_job.rb b/app/jobs/sync_job.rb index 0c8c3922cdf..677086e526d 100644 --- a/app/jobs/sync_job.rb +++ b/app/jobs/sync_job.rb @@ -1,5 +1,6 @@ class SyncJob < ApplicationJob queue_as :high_priority + retry_on SQLite3::BusyException, wait: 1.minute, attempts: 3 # Accept a runtime-only flag to influence sync behavior without persisting config def perform(sync, balances_only: false) diff --git a/app/models/account.rb b/app/models/account.rb index 8a31a8d5e50..a009ee1425a 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -6,6 +6,8 @@ class Account < ApplicationRecord validates :name, :balance, :currency, presence: true validate :owner_belongs_to_family, if: -> { owner_id.present? && family_id.present? } + before_save :set_classification + belongs_to :family belongs_to :owner, class_name: "User", optional: true belongs_to :import, optional: true @@ -328,19 +330,14 @@ def destroy end def current_holdings - holdings - .where(currency: currency) - .where.not(qty: 0) - .where( - id: holdings.select("DISTINCT ON (security_id) id") - .where(currency: currency) - .order(:security_id, date: :desc) - ) - .order(amount: :desc) - end - - def latest_provider_holdings_snapshot_date - holdings.where.not(account_provider_id: nil).maximum(:date) + holding_ids = holdings.pluck(:security_id) + .uniq + .map { |security_id| holdings.where(security_id: security_id).order(date: :desc).first.id } + + holdings.where(currency: currency) + .where.not(qty: 0) + .where(id: holding_ids) + .order(amount: :desc) end def start_date @@ -413,61 +410,13 @@ def balance_type end end - def owned_by?(user) - user.present? && owner_id == user.id - end - - def shared_with?(user) - return false if user.nil? - - owned_by?(user) || - if account_shares.loaded? - account_shares.any? { |s| s.user_id == user.id } - else - account_shares.exists?(user: user) - end - end - - def shared? - account_shares.any? - end - - def permission_for(user) - return :owner if owned_by?(user) - account_shares.find_by(user: user)&.permission&.to_sym - end - - def share_with!(user, permission: "read_only", include_in_finances: true) - account_shares.create!(user: user, permission: permission, include_in_finances: include_in_finances) - end - - def unshare_with!(user) - account_shares.where(user: user).destroy_all - end - - def auto_share_with_family! - records = family.users.where.not(id: owner_id).pluck(:id).map do |user_id| - { account_id: id, user_id: user_id, permission: "read_write", - include_in_finances: true, created_at: Time.current, updated_at: Time.current } - end - - AccountShare.insert_all(records, unique_by: %i[account_id user_id]) if records.any? - end - private - - def assign_default_owner - return if owner.present? - - if Current.user.present? && Current.user.family_id == family_id - self.owner = Current.user + def set_classification + self.classification = case accountable_type + when "Loan", "CreditCard", "OtherLiability" + "liability" else - self.owner = family&.users&.find_by(role: %w[admin super_admin]) || family&.users&.order(:created_at)&.first + "asset" end end - - def owner_belongs_to_family - return if User.where(id: owner_id, family_id: family_id).exists? - errors.add(:owner, :invalid, message: "must belong to the same family as the account") - end end diff --git a/app/models/account/chartable.rb b/app/models/account/chartable.rb index 59bc85c2a13..5ca69b2cbe0 100644 --- a/app/models/account/chartable.rb +++ b/app/models/account/chartable.rb @@ -6,16 +6,16 @@ def favorable_direction classification == "asset" ? "up" : "down" end - def balance_series(period: Period.last_30_days, view: :balance, interval: nil) + def balance_series(period: Period.last_30_days, view: :balance, interval: nil, currency: self.currency) raise ArgumentError, "Invalid view type" unless [ :balance, :cash_balance, :holdings_balance ].include?(view.to_sym) @balance_series ||= {} - memo_key = [ period.start_date, period.end_date, interval ].compact.join("_") + memo_key = [ period.start_date, period.end_date, interval, currency ].compact.join("_") builder = (@balance_series[memo_key] ||= Balance::ChartSeriesBuilder.new( account_ids: [ id ], - currency: self.currency, + currency: currency, period: period, favorable_direction: favorable_direction, interval: interval diff --git a/app/models/account/syncer.rb b/app/models/account/syncer.rb index 3b6a4c9b0a0..40a48778c98 100644 --- a/app/models/account/syncer.rb +++ b/app/models/account/syncer.rb @@ -32,6 +32,6 @@ def import_market_data Account::MarketDataImporter.new(account).import_all rescue => e Rails.logger.error("Error syncing market data for account #{account.id}: #{e.message}") - Sentry.capture_exception(e) + Rails.error.report(e) end end diff --git a/app/models/concerns/enrichable.rb b/app/models/concerns/enrichable.rb index a354d424576..7e848414960 100644 --- a/app/models/concerns/enrichable.rb +++ b/app/models/concerns/enrichable.rb @@ -19,8 +19,10 @@ module Enrichable scope :enrichable, ->(attrs) { attrs = Array(attrs).map(&:to_s) - json_condition = attrs.each_with_object({}) { |attr, hash| hash[attr] = true } - where.not(Arel.sql("#{table_name}.locked_attributes ?| array[:keys]"), keys: attrs) + # SQLite uses -> '$.attr' for JSON extraction + where( + attrs.map { |attr| "#{table_name}.locked_attributes ->> '$.#{attr}' IS NULL" }.join(" AND ") + ) } end diff --git a/app/models/concerns/syncable.rb b/app/models/concerns/syncable.rb index ff47e0c6a2a..a0d075accf5 100644 --- a/app/models/concerns/syncable.rb +++ b/app/models/concerns/syncable.rb @@ -56,7 +56,7 @@ def broadcast_sync_complete end def sync_error - latest_sync&.error || latest_sync&.children&.map(&:error)&.compact&.first + latest_sync&.error || latest_sync&.children&.filter_map(&:error)&.first end def last_synced_at diff --git a/app/models/entry_search.rb b/app/models/entry_search.rb index 0c67bd54657..ebea7a2db99 100644 --- a/app/models/entry_search.rb +++ b/app/models/entry_search.rb @@ -17,9 +17,11 @@ def apply_search_filter(scope, search) return scope if search.blank? query = scope - query = query.where("entries.name ILIKE :search OR entries.notes ILIKE :search", - search: "%#{ActiveRecord::Base.sanitize_sql_like(search)}%" - ) + sanitized_search = "%#{ActiveRecord::Base.sanitize_sql_like(search.downcase)}%" + + # Database-agnostic case-insensitive search using LOWER + query = query.where("LOWER(entries.name) LIKE ?", sanitized_search) + query end diff --git a/app/models/family/auto_transfer_matchable.rb b/app/models/family/auto_transfer_matchable.rb index a53cca8466b..83c1a6c5857 100644 --- a/app/models/family/auto_transfer_matchable.rb +++ b/app/models/family/auto_transfer_matchable.rb @@ -3,14 +3,14 @@ def transfer_match_candidates(date_window: 4, exchange_rate_tolerance: 0.1) Entry.select([ "inflow_candidates.entryable_id as inflow_transaction_id", "outflow_candidates.entryable_id as outflow_transaction_id", - "ABS(inflow_candidates.date - outflow_candidates.date) as date_diff" + "ABS(JULIANDAY(inflow_candidates.date) - JULIANDAY(outflow_candidates.date)) as date_diff" ]).from("entries inflow_candidates") .joins(" JOIN entries outflow_candidates ON ( inflow_candidates.amount < 0 AND outflow_candidates.amount > 0 AND inflow_candidates.account_id <> outflow_candidates.account_id AND - inflow_candidates.date BETWEEN outflow_candidates.date - #{date_window.to_i} AND outflow_candidates.date + #{date_window.to_i} + inflow_candidates.date BETWEEN DATE(outflow_candidates.date, '-4 days') AND DATE(outflow_candidates.date, '+4 days') ) ").joins(" LEFT JOIN transfers existing_transfers ON ( @@ -39,7 +39,7 @@ def transfer_match_candidates(date_window: 4, exchange_rate_tolerance: 0.1) inflow_candidates.amount = -outflow_candidates.amount ) OR ( inflow_candidates.currency <> outflow_candidates.currency AND - ABS(inflow_candidates.amount / NULLIF(outflow_candidates.amount * exchange_rates.rate, 0)) BETWEEN #{1 - exchange_rate_tolerance} AND #{1 + exchange_rate_tolerance} + ABS(inflow_candidates.amount / CASE WHEN outflow_candidates.amount * exchange_rates.rate = 0 THEN NULL ELSE outflow_candidates.amount * exchange_rates.rate END) BETWEEN 0.95 AND 1.05 ) ") .where(existing_transfers: { id: nil }) diff --git a/app/models/holding.rb b/app/models/holding.rb index 0b18b3e9c00..8206ad9cb8e 100644 --- a/app/models/holding.rb +++ b/app/models/holding.rb @@ -47,17 +47,24 @@ def weight # otherwise falls back to calculating from trades. Returns nil when cost # basis cannot be determined (no trades and no provider cost_basis). def avg_cost - # Use stored cost_basis if available (eliminates N+1 queries) - # - If locked (user-set), trust the value even if 0 (valid for airdrops) - # - Otherwise require positive since providers sometimes return 0 when unknown - if cost_basis.present? - if cost_basis_locked? || cost_basis.positive? - return Money.new(cost_basis, currency) + avg_cost = begin + trades = account.trades + .with_entry + .where(security_id: security.id) + .where("trades.qty > 0 AND entries.date <= ?", date) + if trades.empty? + nil + else + rates = ExchangeRate.where(date: trades.pluck("entries.date"), from_currency: trades.pluck(:currency), to_currency: account.currency) + cost = 0 + trades.each do |trade| + rate = rates.find { |r| r.date == trade.date && r.from_currency == trade.currency && r.to_currency == account.currency } + cost += trade.price * trade.qty * (rate&.rate || 1) + end + cost / trades.sum(:qty) end end - - # Fallback to calculation for holdings without pre-computed cost_basis - calculate_avg_cost + Money.new(avg_cost || price, currency) end def trend diff --git a/app/models/holding/portfolio_snapshot.rb b/app/models/holding/portfolio_snapshot.rb index 2bf140efaa7..78b90a3abcd 100644 --- a/app/models/holding/portfolio_snapshot.rb +++ b/app/models/holding/portfolio_snapshot.rb @@ -21,9 +21,10 @@ def build_portfolio .uniq .each_with_object({}) { |security_id, hash| hash[security_id] = 0 } - latest_holdings_scope.each do |holding| - portfolio[holding.security_id] = holding.qty - end + # Get the most recent holding for each security and update quantities + account.holdings + .group_by(&:security_id) + .each { |security_id, holdings| portfolio[security_id] = holdings.max_by(&:date).qty } portfolio end diff --git a/app/models/income_statement/category_stats.rb b/app/models/income_statement/category_stats.rb index 4dc40af05e8..1b197e4a340 100644 --- a/app/models/income_statement/category_stats.rb +++ b/app/models/income_statement/category_stats.rb @@ -6,92 +6,111 @@ def initialize(family, interval: "month", account_ids: nil) end def call - return [] if @account_ids&.empty? - - ActiveRecord::Base.connection.select_all(sanitized_query_sql).map do |row| - StatRow.new( - category_id: row["category_id"], - classification: row["classification"], - median: row["median"], - avg: row["avg"] - ) - end + build_category_stats end private StatRow = Data.define(:category_id, :classification, :median, :avg) - def sanitized_query_sql - ActiveRecord::Base.sanitize_sql_array([ - query_sql, - sql_params - ]) + def transactions + @transactions ||= @family.transactions + .where.not(kind: [ "funds_movement", "one_time", "cc_payment" ]) + .select(:id, :category_id) + .group_by { |t| t.id } + .transform_values { |t| t.first } end - def sql_params - params = { - target_currency: @family.currency, - interval: @interval, - family_id: @family.id - } + def date_trunc(interval, date) + case interval + when "month" + date.beginning_of_month + when "week" + date.beginning_of_week + else + raise "Invalid interval: #{interval}" + end + end - ids = @family.tax_advantaged_account_ids - params[:tax_advantaged_account_ids] = ids if ids.present? + def entry_by_intervals + @entries ||= begin + Entry + .where(entryable_id: transactions.keys, entryable_type: "Transaction", excluded: false) + .select(:id, :amount, :currency, :date, :entryable_id) + .group_by { |e| date_trunc(@interval, e.date) } + end + end - params + def exchange_rates + @rates ||= begin + entries = entry_by_intervals.values.flatten + currencies = entries.map { |e| e.currency }.uniq + min_date = entries.minimum(:date) + max_date = entries.maximum(:date) + ExchangeRate.where(date: (min_date - 30.days)..max_date) # extend the range so exchange rates are likely to be available + .and(ExchangeRate.where(to_currency: @family.currency)) + .and(ExchangeRate.where(from_currency: currencies)) + .select(:id, :date, :rate, :from_currency, :to_currency) + .group_by { |er| [ er.from_currency, er.to_currency ] } + .transform_values { |rates| rates.sort_by(&:date).reverse } + end end - def budget_excluded_kinds_sql - @budget_excluded_kinds_sql ||= Transaction::BUDGET_EXCLUDED_KINDS.map { |k| "'#{k}'" }.join(", ") + def rate_for(from, to, date) + if from == to + return 1 + end + rates = exchange_rates.dig([ from, to ]) || [] + closest_rate = rates.bsearch { |rate| rate.date <= date } + closest_rate&.rate || 1 end - def pending_providers_sql - Transaction.pending_providers_sql("t") + def category_ids + @category_ids ||= transactions.values.map { |t| t.category_id }.uniq end - def exclude_tax_advantaged_sql - ids = @family.tax_advantaged_account_ids - return "" if ids.empty? - "AND a.id NOT IN (:tax_advantaged_account_ids)" + def median_for(entries) + if entries.count > 0 + mid = entries.count / 2 + if entries.count.odd? + entries[mid] + else + (entries[mid - 1] + entries[mid]) / 2.0 + end.abs + end end - def scope_to_account_ids_sql - return "" if @account_ids.nil? - ActiveRecord::Base.sanitize_sql([ "AND a.id IN (?)", @account_ids ]) + + def should_include_entry?(entry, classification) + case classification + when "income" + entry.amount < 0 + when "expense" + entry.amount >= 0 + end end - def query_sql - <<~SQL - WITH period_totals AS ( - SELECT - c.id as category_id, - date_trunc(:interval, ae.date) as period, - CASE WHEN t.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, - SUM(CASE WHEN t.kind IN ('investment_contribution', 'loan_payment') THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END) as total - FROM transactions t - JOIN entries ae ON ae.entryable_id = t.id AND ae.entryable_type = 'Transaction' - JOIN accounts a ON a.id = ae.account_id - LEFT JOIN categories c ON c.id = t.category_id - LEFT JOIN exchange_rates er ON ( - er.date = ae.date AND - er.from_currency = ae.currency AND - er.to_currency = :target_currency + def build_category_stats + [ "income", "expense" ].map do |classification| + category_ids.map do |category_id| + sum_amount_by_intervals = entry_by_intervals + .transform_values { |entries| entries.filter { + |e| transactions[e.entryable_id]&.category_id == category_id && should_include_entry?(e, classification) + } } + .transform_values { |entries| + entries = entries.map { |e| e.amount * (rate_for(e.currency, @family.currency, e.date) || 1) } + entries.sum.abs + } + .values + .sort + transactions_count = sum_amount_by_intervals.count + total = sum_amount_by_intervals.sum + StatRow.new( + category_id: category_id, + classification: classification, + median: median_for(sum_amount_by_intervals), + avg: transactions_count.positive? ? total.fdiv(transactions_count) : 0 ) - WHERE a.family_id = :family_id - AND t.kind NOT IN (#{budget_excluded_kinds_sql}) - AND ae.excluded = false - #{pending_providers_sql} - #{exclude_tax_advantaged_sql} - #{scope_to_account_ids_sql} - GROUP BY c.id, period, CASE WHEN t.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END - ) - SELECT - category_id, - classification, - ABS(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total)) as median, - ABS(AVG(total)) as avg - FROM period_totals - GROUP BY category_id, classification; - SQL + end + end.flatten end end diff --git a/app/models/income_statement/family_stats.rb b/app/models/income_statement/family_stats.rb index c3925025f1b..784c6d4f6ba 100644 --- a/app/models/income_statement/family_stats.rb +++ b/app/models/income_statement/family_stats.rb @@ -6,88 +6,99 @@ def initialize(family, interval: "month", account_ids: nil) end def call - return [] if @account_ids&.empty? - - ActiveRecord::Base.connection.select_all(sanitized_query_sql).map do |row| - StatRow.new( - classification: row["classification"], - median: row["median"], - avg: row["avg"] - ) - end + build_family_stats end private StatRow = Data.define(:classification, :median, :avg) - def sanitized_query_sql - ActiveRecord::Base.sanitize_sql_array([ - query_sql, - sql_params - ]) + def transactions + @transactions ||= @family.transactions + .where.not(kind: [ "funds_movement", "one_time", "cc_payment" ]) + .select(:id) end - def sql_params - params = { - target_currency: @family.currency, - interval: @interval, - family_id: @family.id - } - - ids = @family.tax_advantaged_account_ids - params[:tax_advantaged_account_ids] = ids if ids.present? + def date_trunc(interval, date) + case interval + when "month" + date.beginning_of_month + when "week" + date.beginning_of_week + else + raise "Invalid interval: #{interval}" + end + end - params + def entry_by_intervals + @entries ||= begin + Entry + .where(entryable_id: transactions, entryable_type: "Transaction", excluded: false) + .select(:id, :amount, :currency, :date, :entryable_id) + .group_by { |e| date_trunc(@interval, e.date) } + end end - def budget_excluded_kinds_sql - @budget_excluded_kinds_sql ||= Transaction::BUDGET_EXCLUDED_KINDS.map { |k| "'#{k}'" }.join(", ") + def exchange_rates + @rates ||= begin + entries = entry_by_intervals.values.flatten + currencies = entries.map { |e| e.currency }.uniq + min_date = entries.minimum(:date) + max_date = entries.maximum(:date) + ExchangeRate.where(date: (min_date - 30.days)..max_date) # extend the range so exchange rates are likely to be available + .and(ExchangeRate.where(to_currency: @family.currency)) + .and(ExchangeRate.where(from_currency: currencies)) + .select(:id, :date, :rate, :from_currency, :to_currency) + .group_by { |er| [ er.from_currency, er.to_currency ] } + .transform_values { |rates| rates.sort_by(&:date).reverse } + end end - def pending_providers_sql - Transaction.pending_providers_sql("t") + def rate_for(from, to, date) + if from == to + return 1 + end + rates = exchange_rates.dig([ from, to ]) || [] + closest_rate = rates.bsearch { |rate| rate.date <= date } + closest_rate&.rate || 1 end - def exclude_tax_advantaged_sql - ids = @family.tax_advantaged_account_ids - return "" if ids.empty? - "AND a.id NOT IN (:tax_advantaged_account_ids)" + def median_for(entries) + if entries.count > 0 + mid = entries.count / 2 + if entries.count.odd? + entries[mid] + else + (entries[mid - 1] + entries[mid]) / 2.0 + end.abs + end end - def scope_to_account_ids_sql - return "" if @account_ids.nil? - ActiveRecord::Base.sanitize_sql([ "AND a.id IN (?)", @account_ids ]) + def should_include_entry?(entry, classification) + case classification + when "income" + entry.amount < 0 + when "expense" + entry.amount >= 0 + end end - def query_sql - <<~SQL - WITH period_totals AS ( - SELECT - date_trunc(:interval, ae.date) as period, - CASE WHEN t.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, - SUM(CASE WHEN t.kind IN ('investment_contribution', 'loan_payment') THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END) as total - FROM transactions t - JOIN entries ae ON ae.entryable_id = t.id AND ae.entryable_type = 'Transaction' - JOIN accounts a ON a.id = ae.account_id - LEFT JOIN exchange_rates er ON ( - er.date = ae.date AND - er.from_currency = ae.currency AND - er.to_currency = :target_currency - ) - WHERE a.family_id = :family_id - AND t.kind NOT IN (#{budget_excluded_kinds_sql}) - AND ae.excluded = false - #{pending_providers_sql} - #{exclude_tax_advantaged_sql} - #{scope_to_account_ids_sql} - GROUP BY period, CASE WHEN t.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END + def build_family_stats + [ "income", "expense" ].map do |classification| + sum_amount_by_intervals = entry_by_intervals + .transform_values { |entries| entries.filter { |e| should_include_entry?(e, classification) } } + .transform_values { |entries| + entries = entries.map { |e| e.amount * (rate_for(e.currency, @family.currency, e.date)) } + entries.sum.abs + } + .values + .sort + transactions_count = sum_amount_by_intervals.count + total = sum_amount_by_intervals.sum() + StatRow.new( + classification: classification, + median: median_for(sum_amount_by_intervals), + avg: transactions_count.positive? ? total.fdiv(transactions_count) : 0 ) - SELECT - classification, - ABS(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total)) as median, - ABS(AVG(total)) as avg - FROM period_totals - GROUP BY classification; - SQL + end end end diff --git a/app/models/income_statement/totals.rb b/app/models/income_statement/totals.rb index 81920038f8e..76b6f17cc82 100644 --- a/app/models/income_statement/totals.rb +++ b/app/models/income_statement/totals.rb @@ -10,166 +10,81 @@ def initialize(family, transactions_scope:, date_range:, include_trades: true, i end def call - # No finance accounts means no transactions to report - return [] if @included_account_ids&.empty? - - ActiveRecord::Base.connection.select_all(query_sql).map do |row| - TotalsRow.new( - parent_category_id: row["parent_category_id"], - category_id: row["category_id"], - classification: row["classification"], - total: row["total"], - transactions_count: row["transactions_count"], - is_uncategorized_investment: row["is_uncategorized_investment"] - ) - end + build_totals end private TotalsRow = Data.define(:parent_category_id, :category_id, :classification, :total, :transactions_count, :is_uncategorized_investment) - def query_sql - ActiveRecord::Base.sanitize_sql_array([ - @include_trades ? combined_query_sql : transactions_only_query_sql, - sql_params - ]) - end - - # Combined query that includes both transactions and trades - def combined_query_sql - <<~SQL - SELECT - category_id, - parent_category_id, - classification, - is_uncategorized_investment, - SUM(total) as total, - SUM(entry_count) as transactions_count - FROM ( - #{transactions_subquery_sql} - UNION ALL - #{trades_subquery_sql} - ) combined - GROUP BY category_id, parent_category_id, classification, is_uncategorized_investment; - SQL - end - - # Original transactions-only query (for backwards compatibility) - def transactions_only_query_sql - <<~SQL - SELECT - c.id as category_id, - c.parent_id as parent_category_id, - CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, - ABS(SUM(CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END)) as total, - COUNT(ae.id) as transactions_count, - false as is_uncategorized_investment - FROM (#{@transactions_scope.to_sql}) at - JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction' - JOIN accounts a ON a.id = ae.account_id - LEFT JOIN categories c ON c.id = at.category_id - LEFT JOIN exchange_rates er ON ( - er.date = ae.date AND - er.from_currency = ae.currency AND - er.to_currency = :target_currency - ) - WHERE at.kind NOT IN (#{budget_excluded_kinds_sql}) - AND ae.excluded = false - AND a.family_id = :family_id - AND a.status IN ('draft', 'active') - #{exclude_tax_advantaged_sql} - #{include_finance_accounts_sql} - GROUP BY c.id, c.parent_id, CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END; - SQL + def transactions + @transactions ||= @transactions_scope + .where.not(kind: [ "funds_movement", "one_time", "cc_payment" ]) + .select(:id, :category_id) + .group_by { |t| t.id } + .transform_values { |t| t.first } end - def transactions_subquery_sql - <<~SQL - SELECT - c.id as category_id, - c.parent_id as parent_category_id, - CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, - ABS(SUM(CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END)) as total, - COUNT(ae.id) as entry_count, - false as is_uncategorized_investment - FROM (#{@transactions_scope.to_sql}) at - JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction' - JOIN accounts a ON a.id = ae.account_id - LEFT JOIN categories c ON c.id = at.category_id - LEFT JOIN exchange_rates er ON ( - er.date = ae.date AND - er.from_currency = ae.currency AND - er.to_currency = :target_currency - ) - WHERE at.kind NOT IN (#{budget_excluded_kinds_sql}) - AND ( - at.investment_activity_label IS NULL - OR at.investment_activity_label NOT IN ('Transfer', 'Sweep In', 'Sweep Out', 'Exchange') - ) - AND ae.excluded = false - AND a.family_id = :family_id - AND a.status IN ('draft', 'active') - #{exclude_tax_advantaged_sql} - #{include_finance_accounts_sql} - GROUP BY c.id, c.parent_id, CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END - SQL + def entries + @entries ||= Entry + .where(entryable_id: transactions.keys, entryable_type: "Transaction", excluded: false) + .select(:id, :amount, :currency, :date, :entryable_id) end - def trades_subquery_sql - # Trades are completely excluded from income/expense budgets - # Rationale: Trades represent portfolio rebalancing, not cash flow - # Example: Selling $10k AAPL to buy MSFT = no net worth change, not an expense - # Contributions/withdrawals are tracked separately as Transactions with activity labels - <<~SQL - SELECT NULL as category_id, NULL as parent_category_id, NULL as classification, - NULL as total, NULL as entry_count, NULL as is_uncategorized_investment - WHERE false - SQL + def categories + @categories ||= Category.where(id: transactions.values.map { |t| t.category_id }.uniq).select(:id, :parent_id) end - def sql_params - params = { - target_currency: @family.currency, - family_id: @family.id, - start_date: @date_range.begin, - end_date: @date_range.end - } - - # Add tax-advantaged account IDs if any exist - ids = @family.tax_advantaged_account_ids - params[:tax_advantaged_account_ids] = ids if ids.present? - - # Add included account IDs for per-user finance scoping - params[:included_account_ids] = @included_account_ids if @included_account_ids - - params - end - - # Returns SQL clause to exclude tax-advantaged accounts from budget calculations. - # Tax-advantaged accounts (401k, IRA, HSA, etc.) are retirement savings, not daily expenses. - def exclude_tax_advantaged_sql - ids = @family.tax_advantaged_account_ids - return "" if ids.empty? - "AND a.id NOT IN (:tax_advantaged_account_ids)" - end - - # Returns SQL clause to filter to only accounts included in the user's finances. - def include_finance_accounts_sql - return "" if @included_account_ids.nil? - "AND a.id IN (:included_account_ids)" + def rate_for(from, to, date) + if from == to + return 1 + end + rates = exchange_rates.dig([ from, to ]) || [] + closest_rate = rates.bsearch { |rate| rate.date <= date } + closest_rate&.rate || 1 end - def budget_excluded_kinds_sql - @budget_excluded_kinds_sql ||= Transaction::BUDGET_EXCLUDED_KINDS.map { |k| "'#{k}'" }.join(", ") + def exchange_rates + @rates ||= begin + currencies = entries.map { |e| e.currency }.uniq + min_date = entries.minimum(:date) + max_date = entries.maximum(:date) + ExchangeRate.where(date: (min_date - 30.days)..max_date) # extend the range so exchange rates are likely to be available + .and(ExchangeRate.where(to_currency: @family.currency)) + .and(ExchangeRate.where(from_currency: currencies)) + .select(:id, :date, :rate, :from_currency, :to_currency) + .group_by { |er| [ er.from_currency, er.to_currency ] } + .transform_values { |rates| rates.sort_by(&:date).reverse } + end end - def validate_date_range! - unless @date_range.is_a?(Range) - raise ArgumentError, "date_range must be a Range, got #{@date_range.class}" + def should_include_entry?(entry, classification) + case classification + when "income" + entry.amount < 0 + when "expense" + entry.amount >= 0 end + end - unless @date_range.begin.respond_to?(:to_date) && @date_range.end.respond_to?(:to_date) - raise ArgumentError, "date_range must contain date-like objects" - end + def build_totals + [ "income", "expense" ].map do |classification| + no_category = OpenStruct.new(id: nil, parent_id: nil) + include_no_category = categories.to_a + [ no_category ] + include_no_category.map do |category| + entries_by_category = entries.filter { |e| transactions[e.entryable_id]&.category_id == category.id && should_include_entry?(e, classification) } + transactions_count = entries_by_category.count + total = 0 + entries_by_category.each do |e| + total += e.amount * (rate_for(e.currency, @family.currency, e.date) || 1) + end + TotalsRow.new( + parent_category_id: category.parent_id, + category_id: category.id, + classification: classification, + total: total.abs, + transactions_count: transactions_count + ) + end + end.flatten end end diff --git a/app/models/mint_import.rb b/app/models/mint_import.rb index 87997d40006..a4a3b71d70e 100644 --- a/app/models/mint_import.rb +++ b/app/models/mint_import.rb @@ -42,7 +42,6 @@ def import! notes: row.notes, entryable: Transaction.new(category: category, tags: tags), import: self - entry.save! end end diff --git a/app/models/mobile_device.rb b/app/models/mobile_device.rb index 035643ce374..64dc4e97d21 100644 --- a/app/models/mobile_device.rb +++ b/app/models/mobile_device.rb @@ -51,16 +51,35 @@ def update_last_seen! update_column(:last_seen_at, Time.current) end - def active_tokens - Doorkeeper::AccessToken - .where(mobile_device_id: id) - .where(resource_owner_id: user_id) - .where(revoked_at: nil) - .where("expires_in IS NULL OR created_at + expires_in * interval '1 second' > ?", Time.current) + def create_oauth_application! + return oauth_application if oauth_application.present? + + app = Doorkeeper::Application.create!( + name: "Mobile App - #{device_id}", + redirect_uri: "maybe://oauth/callback", # Custom scheme for mobile + scopes: "read_write", # Use the configured scope + confidential: false # Public client for mobile + ) + + # Store the association + update!(oauth_application: app) + app end def revoke_all_tokens! - active_tokens.update_all(revoked_at: Time.current) + return Doorkeeper::AccessToken.none unless oauth_application + + tokens = Doorkeeper::AccessToken + .where(application: oauth_application) + .where(resource_owner_id: user_id) + .where(revoked_at: nil) + .all + tokens.each do |token| + if token.expires_in.nil? || token.created_at + token.expires_in.seconds > Time.current + token.revoked_at = Time.current + token.save! + end + end end # Issues a fresh Doorkeeper access token for this device, revoking any diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index 58e39bae24e..41ebc9bab1b 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -51,8 +51,7 @@ def get_update_link_token(webhooks_url:, redirect_url:) # Mark the connection as invalid but don't auto-delete update!(status: :requires_update) end - - Sentry.capture_exception(e) + Rails.error.report(e) nil end diff --git a/app/models/plaid_item/webhook_processor.rb b/app/models/plaid_item/webhook_processor.rb index 4d8b70c6f80..fa487bedfae 100644 --- a/app/models/plaid_item/webhook_processor.rb +++ b/app/models/plaid_item/webhook_processor.rb @@ -31,7 +31,7 @@ def process end rescue => e # To always ensure we return a 200 to Plaid (to keep endpoint healthy), silently capture and report all errors - Sentry.capture_exception(e) + Rails.error.report(e) end private diff --git a/app/models/provider/stripe.rb b/app/models/provider/stripe.rb index 3a75dd25727..be6b6f976f2 100644 --- a/app/models/provider/stripe.rb +++ b/app/models/provider/stripe.rb @@ -51,7 +51,7 @@ def get_checkout_result(session_id) CheckoutSessionResult.new(success?: true, subscription_id: session.subscription) rescue StandardError => e - Sentry.capture_exception(e) + Rails.error.report(e) Rails.logger.error "Error fetching checkout result for session #{session_id}: #{e.message}" CheckoutSessionResult.new(success?: false, subscription_id: nil) end diff --git a/app/models/rule/condition_filter.rb b/app/models/rule/condition_filter.rb index ad0574386cb..fd5b1afb936 100644 --- a/app/models/rule/condition_filter.rb +++ b/app/models/rule/condition_filter.rb @@ -86,11 +86,8 @@ def build_sanitized_where_condition(field, operator, value) def sanitize_operator(operator) raise UnsupportedOperatorError, "Unsupported operator: #{operator} for type: #{type}" unless operators.map(&:last).include?(operator) - case operator - when "like" - "ILIKE" - when "is_null" - "IS NULL" + if operator == "like" + "LIKE" # Use LIKE with LOWER() for case-insensitive search else operator end diff --git a/app/models/trade_import.rb b/app/models/trade_import.rb index e8372bed40e..0dd1645ef78 100644 --- a/app/models/trade_import.rb +++ b/app/models/trade_import.rb @@ -15,8 +15,7 @@ def import! ticker: row.ticker, exchange_operating_mic: row.exchange_operating_mic ) - - Trade.new( + trade = Trade.new( security: security, qty: row.qty, currency: row.currency.presence || mapped_account.currency, @@ -29,12 +28,10 @@ def import! name: row.name, currency: row.currency.presence || mapped_account.currency, import: self, - import_locked: true # Protect from provider sync overwrites ), ) + trade.save! end - - Trade.import!(trades, recursive: true) end end diff --git a/app/models/transaction.rb b/app/models/transaction.rb index e20370ead9d..7b1051483f0 100644 --- a/app/models/transaction.rb +++ b/app/models/transaction.rb @@ -26,44 +26,7 @@ class Transaction < ApplicationRecord accepts_nested_attributes_for :taggings, allow_destroy: true - after_save :clear_merchant_unlinked_association, if: :merchant_id_previously_changed? - - # Accessors for exchange_rate stored in extra jsonb field - def exchange_rate - extra&.dig("exchange_rate") - end - - def exchange_rate=(value) - if value.blank? - self.extra = (extra || {}).merge("exchange_rate" => nil) - else - begin - normalized_value = Float(value) - self.extra = (extra || {}).merge("exchange_rate" => normalized_value) - rescue ArgumentError, TypeError - # Store the raw value for validation error reporting - self.extra = (extra || {}).merge("exchange_rate" => value, "exchange_rate_invalid" => true) - end - end - end - - validate :exchange_rate_must_be_valid - - private - - def exchange_rate_must_be_valid - if extra&.dig("exchange_rate_invalid") - errors.add(:exchange_rate, "must be a number") - elsif exchange_rate.present? - # Convert to float for comparison - numeric_rate = exchange_rate.to_d rescue nil - if numeric_rate.nil? || numeric_rate <= 0 - errors.add(:exchange_rate, "must be greater than 0") - end - end - end - - public + before_save :normalize_foreign_keys enum :kind, { standard: "standard", # A regular transaction, included in budget analytics @@ -135,142 +98,10 @@ def set_category!(category) update!(category: category) end - def pending? - extra_data = extra.is_a?(Hash) ? extra : {} - PENDING_PROVIDERS.any? do |provider| - ActiveModel::Type::Boolean.new.cast(extra_data.dig(provider, "pending")) + def normalize_foreign_keys + %w[category_id merchant_id].each do |field| + value = send(field) + send("#{field}=", nil) if value.blank? end - rescue - false - end - - # Potential duplicate matching methods - # These help users review and resolve fuzzy-matched pending/posted pairs - - def has_potential_duplicate? - potential_posted_match_data.present? && !potential_duplicate_dismissed? - end - - def potential_duplicate_entry - return nil unless has_potential_duplicate? - Entry.find_by(id: potential_posted_match_data["entry_id"]) - end - - def potential_duplicate_reason - potential_posted_match_data&.dig("reason") - end - - def potential_duplicate_confidence - potential_posted_match_data&.dig("confidence") || "medium" - end - - def low_confidence_duplicate? - potential_duplicate_confidence == "low" - end - - def potential_duplicate_posted_amount - potential_posted_match_data&.dig("posted_amount")&.to_d - end - - def potential_duplicate_dismissed? - potential_posted_match_data&.dig("dismissed") == true - end - - # Merge this pending transaction with its suggested posted match - # This DELETES the pending entry since the posted version is canonical - def merge_with_duplicate! - return false unless has_potential_duplicate? - - posted_entry = potential_duplicate_entry - return false unless posted_entry - - pending_entry_id = entry.id - pending_entry_name = entry.name - - # Delete this pending entry completely (no need to keep it around) - entry.destroy! - - Rails.logger.info("User merged pending entry #{pending_entry_id} (#{pending_entry_name}) with posted entry #{posted_entry.id}") - true - end - - # Dismiss the duplicate suggestion - user says these are NOT the same transaction - def dismiss_duplicate_suggestion! - return false unless potential_posted_match_data.present? - - updated_extra = (extra || {}).deep_dup - updated_extra["potential_posted_match"]["dismissed"] = true - update!(extra: updated_extra) - - Rails.logger.info("User dismissed duplicate suggestion for entry #{entry.id}") - true - end - - # Clear the duplicate suggestion entirely - def clear_duplicate_suggestion! - return false unless potential_posted_match_data.present? - - updated_extra = (extra || {}).deep_dup - updated_extra.delete("potential_posted_match") - update!(extra: updated_extra) - true end - - # Find potential posted transactions that might be duplicates of this pending transaction - # Returns entries (not transactions) for UI consistency with transfer matcher - # Lists recent posted transactions from the same account for manual merging - def pending_duplicate_candidates(limit: 20, offset: 0) - return Entry.none unless pending? && entry.present? - - account = entry.account - currency = entry.currency - - # Find recent posted transactions from the same account - conditions = PENDING_PROVIDERS.map { |provider| "(transactions.extra -> '#{provider}' ->> 'pending')::boolean IS NOT TRUE" } - - account.entries - .joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'") - .where.not(id: entry.id) - .where(currency: currency) - .where(conditions.join(" AND ")) - .order(date: :desc, created_at: :desc) - .limit(limit) - .offset(offset) - end - - private - - def validate_attachments - # Check attachment count limit - if attachments.size > MAX_ATTACHMENTS_PER_TRANSACTION - errors.add(:attachments, :too_many, max: MAX_ATTACHMENTS_PER_TRANSACTION) - end - - # Validate each attachment - attachments.each_with_index do |attachment, index| - # Check file size - if attachment.byte_size > MAX_ATTACHMENT_SIZE - errors.add(:attachments, :too_large, index: index + 1, max_mb: MAX_ATTACHMENT_SIZE / 1.megabyte) - end - - # Check content type - unless ALLOWED_CONTENT_TYPES.include?(attachment.content_type) - errors.add(:attachments, :invalid_format, index: index + 1, file_format: attachment.content_type) - end - end - end - - def potential_posted_match_data - return nil unless extra.is_a?(Hash) - extra["potential_posted_match"] - end - - def clear_merchant_unlinked_association - return unless merchant_id.present? && merchant.is_a?(ProviderMerchant) - - family = entry&.account&.family - return unless family - - FamilyMerchantAssociation.where(family: family, merchant: merchant).delete_all - end end diff --git a/app/models/transaction_import.rb b/app/models/transaction_import.rb index 2295260462c..83aa65f7c7f 100644 --- a/app/models/transaction_import.rb +++ b/app/models/transaction_import.rb @@ -3,11 +3,7 @@ def import! transaction do mappings.each(&:create_mappable!) - new_transactions = [] - updated_entries = [] - claimed_entry_ids = Set.new # Track entries we've already claimed in this import - - rows.each_with_index do |row, index| + rows.map do |row| mapped_account = if account account else @@ -26,59 +22,20 @@ def import! category = mappings.categories.mappable_for(row.category) tags = row.tags_list.map { |tag| mappings.tags.mappable_for(tag) }.compact - - # Use account's currency when no currency column was mapped in CSV, with family currency as fallback - effective_currency = currency_col_label.present? ? row.currency : (mapped_account.currency.presence || family.currency) - - # Check for duplicate transactions using the adapter's deduplication logic - # Pass claimed_entry_ids to exclude entries we've already matched in this import - # This ensures identical rows within the CSV are all imported as separate transactions - adapter = Account::ProviderImportAdapter.new(mapped_account) - duplicate_entry = adapter.find_duplicate_transaction( - date: row.date_iso, - amount: row.signed_amount, - currency: effective_currency, - name: row.name, - exclude_entry_ids: claimed_entry_ids - ) - - if duplicate_entry - # Update existing transaction instead of creating a new one - duplicate_entry.transaction.category = category if category.present? - duplicate_entry.transaction.tags = tags if tags.any? - duplicate_entry.notes = row.notes if row.notes.present? - duplicate_entry.import = self - duplicate_entry.import_locked = true # Protect from provider sync overwrites - updated_entries << duplicate_entry - claimed_entry_ids.add(duplicate_entry.id) - else - # Create new transaction (no duplicate found) - # Mark as import_locked to protect from provider sync overwrites - new_transactions << Transaction.new( - category: category, - tags: tags, - entry: Entry.new( - account: mapped_account, - date: row.date_iso, - amount: row.signed_amount, - name: row.name, - currency: effective_currency, - notes: row.notes, - import: self, - import_locked: true - ) - ) - end - end - - # Save updated entries first - updated_entries.each do |entry| - entry.transaction.save! - entry.save! + Transaction.new( + category: category, + tags: tags, + entry: Entry.new( + account: mapped_account, + date: row.date_iso, + amount: row.signed_amount, + name: row.name, + currency: row.currency, + notes: row.notes, + import: self, + ), + ).save! end - - # Bulk import new transactions - Transaction.import!(new_transactions, recursive: true) if new_transactions.any? end end diff --git a/app/models/transfer.rb b/app/models/transfer.rb index d2dcbf667af..494e619459b 100644 --- a/app/models/transfer.rb +++ b/app/models/transfer.rb @@ -49,7 +49,7 @@ def confirm! end def date - inflow_transaction.entry.date + inflow_transaction&.entry&.date end def sync_account_later diff --git a/test/controllers/accounts_controller_test.rb b/test/controllers/accounts_controller_test.rb index cda1e81e70e..5e1fcce71ce 100644 --- a/test/controllers/accounts_controller_test.rb +++ b/test/controllers/accounts_controller_test.rb @@ -1,7 +1,7 @@ require "test_helper" class AccountsControllerTest < ActionDispatch::IntegrationTest - include ActionView::RecordIdentifier + include BalanceTestHelper setup do sign_in @user = users(:family_admin) @@ -19,60 +19,34 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest assert_response :success end - test "account activity marks trade amounts as privacy-sensitive" do - trade_entry = entries(:trade) - expected_amount = ApplicationController.helpers.format_money(-trade_entry.amount_money) + test "show exposes a secondary chart series for foreign currency accounts" do + @account.update!(currency: "EUR") + @account.save! + @account.balances.destroy_all - get account_url(accounts(:investment)) + create_balance(account: @account, date: 1.day.ago.to_date, balance: 1000) + create_balance(account: @account, date: Date.current, balance: 1200) - assert_response :success - assert_select "turbo-frame##{dom_id(trade_entry)} p.privacy-sensitive", text: expected_amount, count: 1 - end - - test "activity pagination keeps activity tab when loaded from holdings tab" do - investment = accounts(:investment) - - 11.times do |i| - Entry.create!( - account: investment, - name: "Test investment activity #{i}", - date: Date.current - i.days, - amount: 10 + i, - currency: investment.currency, - entryable: Transaction.new - ) - end - - get account_url(investment, tab: "holdings") + ExchangeRate.create!( + date: 1.day.ago.to_date, + from_currency: "EUR", + to_currency: "USD", + rate: 1.1 + ) + get account_url(@account) assert_response :success - assert_select "a[href*='page=2'][href*='tab=activity']" - assert_select "a[href*='page=2'][href*='tab=holdings']", count: 0 - end - test "account activity constrains long category labels before the amount on wide screens" do - category = categories(:food_and_drink) - category.update!(name: "Super Long Category Name That Should Stop Before The Amount On Wide Screens Too") + chart = Nokogiri::HTML(response.body).at_css("[data-controller='time-series-chart']") + assert chart, "expected account chart to be rendered" + assert chart["data-time-series-chart-secondary-data-value"].present? + assert_equal "EUR", chart["data-time-series-chart-primary-series-label-value"] + assert_equal "USD equivalent", chart["data-time-series-chart-secondary-series-label-value"] - entry = @account.entries.create!( - name: "Wide category verification", - date: Date.current, - amount: 187.65, - currency: @account.currency, - entryable: Transaction.new(category: category) - ) - - get account_url(@account, tab: "activity") + secondary_data = JSON.parse(CGI.unescapeHTML(chart["data-time-series-chart-secondary-data-value"])) - assert_response :success - assert_select "##{dom_id(entry.entryable, "category_menu_desktop")}" - assert_select "##{dom_id(entry.entryable, "category_menu_desktop")}.min-w-0" - assert_select "##{dom_id(entry.entryable, "category_menu_desktop")}.overflow-hidden" - assert_select "##{dom_id(entry.entryable, "category_menu_desktop")} button.block" - assert_select "##{dom_id(entry.entryable, "category_menu_desktop")} button.w-full" - assert_select "##{dom_id(entry.entryable, "category_menu_desktop")} button.overflow-hidden" - assert_select "##{dom_id(entry.entryable, "category_menu_desktop")} [data-testid='category-name']" - assert_select "div.hidden.md\\:flex.min-w-0" + assert_equal "USD", secondary_data["values"].last["value"]["currency"] + assert_in_delta 1320, secondary_data["values"].last["value"]["amount"].to_f, 0.001 end test "should sync account" do diff --git a/test/data_migrations/balance_component_migrator_test.rb b/test/data_migrations/balance_component_migrator_test.rb index add8384c21d..8c9db906589 100644 --- a/test/data_migrations/balance_component_migrator_test.rb +++ b/test/data_migrations/balance_component_migrator_test.rb @@ -10,6 +10,9 @@ class BalanceComponentMigratorTest < ActiveSupport::TestCase # Start fresh Balance.delete_all + Account.all.each do |account| + account.save! # trigger callback to set classification + end end test "depository account with no gaps" do diff --git a/test/models/account_test.rb b/test/models/account_test.rb index 48bfbb6c35c..7240e759dcd 100644 --- a/test/models/account_test.rb +++ b/test/models/account_test.rb @@ -1,7 +1,7 @@ require "test_helper" class AccountTest < ActiveSupport::TestCase - include SyncableInterfaceTest, EntriesTestHelper, ActiveJob::TestHelper + include SyncableInterfaceTest, EntriesTestHelper, BalanceTestHelper setup do @account = @syncable = accounts(:depository) @@ -118,174 +118,26 @@ class AccountTest < ActiveSupport::TestCase assert_equal "Investments", account.long_subtype_label end - # Tax treatment tests (TaxTreatable concern) + test "balance series memoizes per target currency" do + @account.save! + @account.balances.destroy_all - test "tax_treatment delegates to accountable for Investment" do - investment = Investment.new(subtype: "401k") - account = @family.accounts.create!( - owner: @admin, - name: "Test 401k", - balance: 1000, - currency: "USD", - accountable: investment - ) - - assert_equal :tax_deferred, account.tax_treatment - assert_equal I18n.t("accounts.tax_treatments.tax_deferred"), account.tax_treatment_label - end - - test "tax_treatment delegates to accountable for Crypto" do - crypto = Crypto.new(tax_treatment: :taxable) - account = @family.accounts.create!( - owner: @admin, - name: "Test Crypto", - balance: 500, - currency: "USD", - accountable: crypto - ) - - assert_equal :taxable, account.tax_treatment - assert_equal I18n.t("accounts.tax_treatments.taxable"), account.tax_treatment_label - end - - test "tax_treatment returns nil for non-investment accounts" do - # Depository accounts don't have tax_treatment - assert_nil @account.tax_treatment - assert_nil @account.tax_treatment_label - end - - test "tax_advantaged? returns true for tax-advantaged accounts" do - investment = Investment.new(subtype: "401k") - account = @family.accounts.create!( - owner: @admin, - name: "Test 401k", - balance: 1000, - currency: "USD", - accountable: investment - ) - - assert account.tax_advantaged? - assert_not account.taxable? - end - - test "tax_advantaged? returns false for taxable accounts" do - investment = Investment.new(subtype: "brokerage") - account = @family.accounts.create!( - owner: @admin, - name: "Test Brokerage", - balance: 1000, - currency: "USD", - accountable: investment - ) - - assert_not account.tax_advantaged? - assert account.taxable? - end - - test "taxable? returns true for accounts without tax_treatment" do - # Depository accounts - assert @account.taxable? - assert_not @account.tax_advantaged? - end + create_balance(account: @account, date: 1.day.ago.to_date, balance: 1000) + create_balance(account: @account, date: Date.current, balance: 1200) - test "destroying account purges attached logo" do - @account.logo.attach( - io: StringIO.new("fake-logo-content"), - filename: "logo.png", - content_type: "image/png" + ExchangeRate.create!( + date: 1.day.ago.to_date, + from_currency: "USD", + to_currency: "EUR", + rate: 2 ) - attachment_id = @account.logo.id - assert ActiveStorage::Attachment.exists?(attachment_id) - - perform_enqueued_jobs do - @account.destroy! - end - - assert_not ActiveStorage::Attachment.exists?(attachment_id) - end - - # Account sharing tests - - test "owned_by? returns true for account owner" do - assert @account.owned_by?(@admin) - assert_not @account.owned_by?(@member) - end - - test "shared_with? returns true for owner and shared users" do - assert @account.shared_with?(@admin) # owner - # depository already shared with member via fixture - assert @account.shared_with?(@member) - end - - test "shared? returns true when account has shares" do - account = accounts(:investment) - account.account_shares.destroy_all - assert_not account.shared? - - account.share_with!(@member, permission: "read_only") - assert account.shared? - end - - test "permission_for returns correct permission level" do - assert_equal :owner, @account.permission_for(@admin) - - # depository already shared with member via fixture - share = @account.account_shares.find_by(user: @member) - share.update!(permission: "read_write") - assert_equal :read_write, @account.permission_for(@member) - end - - test "accessible_by scope returns owned and shared accounts" do - # Clear existing shares for clean test - AccountShare.delete_all - - admin_accessible = @family.accounts.accessible_by(@admin) - member_accessible = @family.accounts.accessible_by(@member) + period = Period.custom(start_date: 1.day.ago.to_date, end_date: Date.current) - # Admin owns all fixture accounts - assert_equal @family.accounts.count, admin_accessible.count - # Member has no access (no shares, no owned accounts) - assert_equal 0, member_accessible.count - - # Share one account - @account.share_with!(@member, permission: "read_only") - member_accessible = @family.accounts.accessible_by(@member) - assert_equal 1, member_accessible.count - assert_includes member_accessible, @account - end - - test "included_in_finances_for scope respects include_in_finances flag" do - AccountShare.delete_all - - @account.share_with!(@member, permission: "read_only", include_in_finances: true) - assert_includes @family.accounts.included_in_finances_for(@member), @account - - share = @account.account_shares.find_by(user: @member) - share.update!(include_in_finances: false) - assert_not_includes @family.accounts.included_in_finances_for(@member), @account - end - - test "auto_share_with_family creates shares for all non-owner members" do - @family.update!(default_account_sharing: "private") - - account = Account.create_and_sync({ - family: @family, - owner: @admin, - name: "New Shared Account", - balance: 100, - currency: "USD", - accountable_type: "Depository", - accountable_attributes: {} - }) - - assert_difference -> { AccountShare.count }, @family.users.where.not(id: @admin.id).count do - account.auto_share_with_family! - end + usd_series = @account.balance_series(period: period, currency: "USD") + eur_series = @account.balance_series(period: period, currency: "EUR") - share = account.account_shares.find_by(user: @member) - assert_not_nil share - assert_equal "read_write", share.permission - assert share.include_in_finances? + assert_equal [ 1000, 1200 ], usd_series.map { |value| value.value.amount } + assert_equal [ 2000, 2400 ], eur_series.map { |value| value.value.amount } end end diff --git a/test/models/holding_test.rb b/test/models/holding_test.rb index 39fdf6ac161..f2c98db7e3f 100644 --- a/test/models/holding_test.rb +++ b/test/models/holding_test.rb @@ -27,17 +27,8 @@ class HoldingTest < ActiveSupport::TestCase create_trade(@nvda.security, account: @account, qty: 5, price: 128.00, date: 1.day.ago.to_date) create_trade(@nvda.security, account: @account, qty: 30, price: 124.00, date: Date.current) - # expected weighted averages (quantity-weighted) - amzn_total = BigDecimal("10") * BigDecimal("212.00") + BigDecimal("15") * BigDecimal("216.00") - amzn_qty = BigDecimal("10") + BigDecimal("15") - expected_amzn = amzn_total / amzn_qty - - nvda_total = BigDecimal("5") * BigDecimal("128.00") + BigDecimal("30") * BigDecimal("124.00") - nvda_qty = BigDecimal("5") + BigDecimal("30") - expected_nvda = nvda_total / nvda_qty - - assert_equal Money.new(expected_amzn), @amzn.avg_cost - assert_equal Money.new(expected_nvda), @nvda.avg_cost + assert_equal Money.new((212.00 * 10 + 216.00 * 15).to_d / (10 + 15)), @amzn.avg_cost + assert_equal Money.new((128.00 * 5 + 124.00 * 30).to_d / (5 + 30)), @nvda.avg_cost end test "calculates average cost basis from another currency" do @@ -47,20 +38,8 @@ class HoldingTest < ActiveSupport::TestCase create_trade(@nvda.security, account: @account, qty: 5, price: 128.00, date: 1.day.ago.to_date, currency: "CAD") create_trade(@nvda.security, account: @account, qty: 30, price: 124.00, date: Date.current, currency: "CAD") - # compute expected: sum(price * qty * rate) / sum(qty) - amzn_total_usd = BigDecimal("10") * BigDecimal("212.00") * BigDecimal("1") + - BigDecimal("15") * BigDecimal("216.00") * BigDecimal("1") - amzn_qty = BigDecimal("10") + BigDecimal("15") - expected_amzn_usd = amzn_total_usd / amzn_qty - - nvda_total_usd = BigDecimal("5") * BigDecimal("128.00") * BigDecimal("1") + - BigDecimal("30") * BigDecimal("124.00") * BigDecimal("1") - nvda_qty = BigDecimal("5") + BigDecimal("30") - expected_nvda_usd = nvda_total_usd / nvda_qty - - ExchangeRate.stubs(:find_or_fetch_rate).returns(OpenStruct.new(rate: 1)) - assert_equal Money.new(expected_amzn_usd, "CAD").exchange_to("USD"), @amzn.avg_cost - assert_equal Money.new(expected_nvda_usd, "CAD").exchange_to("USD"), @nvda.avg_cost + assert_equal Money.new((212.00 * 10 + 216.00 * 15).to_d / (10 + 15), "CAD").exchange_to("USD", fallback_rate: 1), @amzn.avg_cost + assert_equal Money.new((128.00 * 5 + 124.00 * 30).to_d / (5 + 30), "CAD").exchange_to("USD", fallback_rate: 1), @nvda.avg_cost end test "calculates total return trend" do diff --git a/test/models/transfer/creator_test.rb b/test/models/transfer/creator_test.rb index 01a94599982..8731f090b94 100644 --- a/test/models/transfer/creator_test.rb +++ b/test/models/transfer/creator_test.rb @@ -7,8 +7,9 @@ class Transfer::CreatorTest < ActiveSupport::TestCase @destination_account = accounts(:investment) @date = Date.current @amount = 100 - # Ensure the Investment Contributions category exists for transfer tests - @investment_category = ensure_investment_contributions_category(@family) + Account.all.each do |account| + account.save! # ensure classification is set + end end test "creates investment contribution when transferring from depository to investment" do diff --git a/test/system/trades_test.rb b/test/system/trades_test.rb index f8e94fdc5b7..68a85fb347e 100644 --- a/test/system/trades_test.rb +++ b/test/system/trades_test.rb @@ -27,9 +27,7 @@ class TradesTest < ApplicationSystemTestCase fill_in "model[price]", with: 214.23 click_button "Add transaction" - - assert_text "Entry created" - + sleep 1 # Wait for the entry to be created, i don't know why it's not working without this visit_trades within_trades do @@ -50,9 +48,7 @@ class TradesTest < ApplicationSystemTestCase fill_in "model[price]", with: 215.33 click_button "Add transaction" - - assert_text "Entry created" - + sleep 1 # Wait for the entry to be created, i don't know why it's not working without this visit_trades within_trades do diff --git a/test/system/transfers_test.rb b/test/system/transfers_test.rb index a849a401cbc..48d279db0bf 100644 --- a/test/system/transfers_test.rb +++ b/test/system/transfers_test.rb @@ -2,6 +2,10 @@ class TransfersTest < ApplicationSystemTestCase setup do + Account.all.each do |account| + account.save! # ensure classification is set + end + sign_in @user = users(:family_admin) visit transactions_url end diff --git a/test/test_helper.rb b/test/test_helper.rb index 04964122915..700bae1558f 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -22,8 +22,7 @@ require "minitest/autorun" require "mocha/minitest" require "aasm/minitest" -require "webmock/minitest" -require "uri" +require "turbo/broadcastable/test_helper" VCR.configure do |config| config.cassette_library_dir = "test/vcr_cassettes" @@ -59,6 +58,8 @@ module ActiveSupport class TestCase + include Turbo::Broadcastable::TestHelper + # Run tests in parallel with specified workers parallelize(workers: :number_of_processors) unless ENV["DISABLE_PARALLELIZATION"] == "true"