diff --git a/.env.example b/.env.example index 1912a9260..56568fc88 100644 --- a/.env.example +++ b/.env.example @@ -70,8 +70,16 @@ POSTGRES_PASSWORD=postgres 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 + # 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/publish.yml b/.github/workflows/publish.yml index 66ceff881..955d15652 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -344,7 +344,7 @@ jobs: uses: actions/checkout@v4.2.0 with: ref: main - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ secrets.GH_PAT }} - name: Bump alpha version run: | @@ -386,6 +386,7 @@ jobs: # Verify the change echo "Updated version.rb:" + grep "semver" "$VERSION_FILE" - name: Commit and push version bump run: | @@ -398,6 +399,7 @@ jobs: if git diff --cached --quiet; then echo "No changes to commit - version may have already been bumped" exit 0 + fi git commit -m "Bump version to next alpha after ${{ github.ref_name }} release" diff --git a/CLAUDE.md b/CLAUDE.md index f543d5bd9..eb1688d90 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,10 +100,14 @@ Two primary data ingestion methods: - SimpleFIN: pending via `pending: true` or `posted` blank/0 + `transacted_at`. - Plaid: pending via Plaid `pending: true` (stored at `extra["plaid"]["pending"]` for bank/credit transactions imported via `PlaidEntry::Processor`). - Storage: provider data on `Transaction#extra` (e.g., `extra["simplefin"]["pending"]`; FX uses `fx_from`, `fx_date`). -- UI: “Pending” badge when `transaction.pending?` is true; no badge if provider omits pendings. -- Configuration (default-off) - - Centralized in `config/initializers/simplefin.rb` via `Rails.configuration.x.simplefin.*`. - - ENV-backed keys: `SIMPLEFIN_INCLUDE_PENDING=1`, `SIMPLEFIN_DEBUG_RAW=1`. +- UI: "Pending" badge when `transaction.pending?` is true; no badge if provider omits pendings. +- Configuration (default-on for pending) + - SimpleFIN: `config/initializers/simplefin.rb` via `Rails.configuration.x.simplefin.*`. + - Plaid: `config/initializers/plaid_config.rb` via `Rails.configuration.x.plaid.*`. + - Pending transactions are fetched by default and handled via reconciliation/filtering. + - Set `SIMPLEFIN_INCLUDE_PENDING=0` to disable pending fetching for SimpleFIN. + - Set `PLAID_INCLUDE_PENDING=0` to disable pending fetching for Plaid. + - Set `SIMPLEFIN_DEBUG_RAW=1` to enable raw payload debug logging. Provider support notes: - SimpleFIN: supports pending + FX metadata (stored under `extra["simplefin"]`). diff --git a/Gemfile b/Gemfile index ffd651e67..b571c3c8f 100644 --- a/Gemfile +++ b/Gemfile @@ -59,6 +59,7 @@ gem "countries" # OAuth & API Security gem "doorkeeper" gem "rack-attack", "~> 6.6" +gem "pundit" gem "faraday" gem "faraday-retry" gem "faraday-multipart" @@ -68,6 +69,7 @@ gem "pagy" gem "rails-settings-cached" gem "tzinfo-data", platforms: %i[windows jruby] gem "csv" +gem "rchardet" # Character encoding detection gem "redcarpet" gem "stripe" gem "plaid" @@ -77,17 +79,22 @@ gem "rqrcode", "~> 3.0" gem "activerecord-import" gem "rubyzip", "~> 2.3" -# OpenID Connect & OAuth authentication +# OpenID Connect, OAuth & SAML authentication gem "omniauth", "~> 2.1" gem "omniauth-rails_csrf_protection" gem "omniauth_openid_connect" gem "omniauth-google-oauth2" gem "omniauth-github" +gem "omniauth-saml", "~> 2.1" # State machines gem "aasm" gem "after_commit_everywhere", "~> 1.0" +# Feature flags +gem "flipper" +gem "flipper-active_record" + # AI gem "ruby-openai" gem "langfuse-ruby", "~> 0.1.4", require: "langfuse" diff --git a/Gemfile.lock b/Gemfile.lock index 86435af42..c50c69d92 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -216,6 +216,11 @@ GEM ffi (1.17.2-x86_64-darwin) ffi (1.17.2-x86_64-linux-gnu) ffi (1.17.2-x86_64-linux-musl) + flipper (1.3.6) + concurrent-ruby (< 2) + flipper-active_record (1.3.6) + activerecord (>= 4.2, < 9) + flipper (~> 1.3.6) foreman (0.88.1) fugit (1.11.1) et-orbi (~> 1, >= 1.2.11) @@ -415,6 +420,9 @@ GEM 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) @@ -461,6 +469,8 @@ GEM public_suffix (6.0.2) puma (6.6.0) nio4r (~> 2.0) + pundit (2.5.2) + activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) rack (3.1.18) @@ -528,6 +538,7 @@ GEM ffi (~> 1.0) rbs (3.9.4) logger + rchardet (1.10.0) rdoc (6.14.2) erb psych (>= 4.0.0) @@ -614,6 +625,9 @@ GEM faraday (>= 1) faraday-multipart (>= 1) ruby-progressbar (1.13.0) + ruby-saml (1.18.1) + nokogiri (>= 1.13.10) + rexml ruby-statistics (4.1.0) ruby-vips (2.2.4) ffi (~> 1.12) @@ -769,6 +783,8 @@ DEPENDENCIES faraday faraday-multipart faraday-retry + flipper + flipper-active_record foreman hotwire-livereload hotwire_combobox @@ -790,6 +806,7 @@ DEPENDENCIES omniauth-github omniauth-google-oauth2 omniauth-rails_csrf_protection + omniauth-saml (~> 2.1) omniauth_openid_connect ostruct pagy @@ -798,10 +815,12 @@ DEPENDENCIES posthog-ruby propshaft puma (>= 5.0) + pundit rack-attack (~> 6.6) rack-mini-profiler rails (~> 7.2.2) rails-settings-cached + rchardet redcarpet redis (~> 5.4) rotp (~> 6.3) diff --git a/README.md b/README.md index 48654668d..876a0f452 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/we-promise/sure) +[![Dosu](https://raw.githubusercontent.com/dosu-ai/assets/main/dosu-badge.svg)](https://app.dosu.dev/a72bdcfd-15f5-4edc-bd85-ea0daa6c3adc/ask) sure_shot diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css index 938f57d5c..c7439e481 100644 --- a/app/assets/tailwind/application.css +++ b/app/assets/tailwind/application.css @@ -12,6 +12,7 @@ @import "./google-sign-in.css"; @import "./date-picker-dark-mode.css"; +@import "./print-report.css"; @layer components { .pcr-app{ diff --git a/app/assets/tailwind/print-report.css b/app/assets/tailwind/print-report.css new file mode 100644 index 000000000..1917349a9 --- /dev/null +++ b/app/assets/tailwind/print-report.css @@ -0,0 +1,296 @@ +/* + Print Report Styles + Tufte-inspired styling for the printable financial report. + Uses design system tokens where applicable. +*/ + +/* Print Body & Container */ +.print-body { + background: var(--color-white); + color: var(--color-gray-900); + font-family: var(--font-sans); + line-height: 1.5; +} + +.print-container { + max-width: 680px; + margin: 0 auto; + padding: 32px 24px; +} + +.tufte-report { + font-size: 11px; + color: var(--color-gray-900); +} + +/* Header */ +.tufte-header { + margin-bottom: 24px; + padding-bottom: 12px; + border-bottom: 2px solid var(--color-gray-900); +} + +.tufte-title { + font-size: 20px; + font-weight: 700; + margin: 0 0 4px 0; + color: var(--color-gray-900); + letter-spacing: -0.3px; +} + +.tufte-period { + display: block; + font-size: 13px; + font-weight: 500; + color: var(--color-gray-600); + margin-top: 2px; +} + +.tufte-meta { + font-size: 10px; + color: var(--color-gray-500); + margin: 8px 0 0 0; +} + +/* Sections */ +.tufte-section { + margin-bottom: 24px; +} + +.tufte-section-title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--color-gray-900); + margin: 0 0 12px 0; + border-bottom: 1px solid var(--color-gray-200); + padding-bottom: 6px; +} + +.tufte-subsection { + font-size: 11px; + font-weight: 600; + margin: 16px 0 8px 0; + padding-bottom: 4px; + border-bottom: 1px solid var(--color-gray-100); +} + +/* Metric Cards */ +.tufte-metric-card { + display: inline-block; + min-width: 100px; +} + +.tufte-metric-card-main { + display: block; +} + +.tufte-metric-card-label { + display: block; + font-size: 10px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--color-gray-500); + margin-bottom: 4px; +} + +.tufte-metric-card-value { + display: block; + font-size: 20px; + font-weight: 700; + font-variant-numeric: tabular-nums; + line-height: 1.1; + letter-spacing: -0.5px; +} + +.tufte-metric-card-change { + display: inline-block; + font-size: 10px; + font-weight: 500; + margin-top: 4px; + padding: 1px 4px; + border-radius: 2px; +} + +.tufte-metric-card-sm .tufte-metric-card-value { + font-size: 16px; +} + +.tufte-metric-card-sm .tufte-metric-card-label { + font-size: 9px; +} + +/* Metric Row (horizontal layout) */ +.tufte-metric-row { + display: flex; + gap: 32px; + flex-wrap: wrap; + margin-bottom: 8px; +} + +/* Semantic Colors */ +.tufte-income { color: var(--color-green-700); } +.tufte-expense { color: var(--color-red-700); } +.tufte-muted { color: var(--color-gray-500); font-size: 10px; } +.tufte-up { color: var(--color-green-700); } +.tufte-down { color: var(--color-red-700); } + +/* Two Column Layout */ +.tufte-two-col { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 32px; + margin-top: 12px; +} + +/* Tables - Clean, readable style */ +.tufte-table { + width: 100%; + border-collapse: collapse; + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +.tufte-table thead th { + text-align: left; + font-weight: 600; + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--color-gray-600); + padding: 8px 12px 8px 0; + border-bottom: 2px solid var(--color-gray-900); +} + +.tufte-table tbody td { + padding: 6px 12px 6px 0; + border-bottom: 1px solid var(--color-gray-200); + vertical-align: middle; +} + +.tufte-table tbody tr:last-child td { + border-bottom: none; +} + +.tufte-table tfoot td { + padding: 8px 12px 6px 0; + border-top: 2px solid var(--color-gray-900); + font-weight: 600; +} + +.tufte-table.tufte-compact thead th { + padding: 6px 8px 6px 0; +} + +.tufte-table.tufte-compact tbody td { + padding: 5px 8px 5px 0; +} + +.tufte-right { + text-align: right; + padding-right: 0 !important; +} + +.tufte-highlight { + background: var(--color-yellow-100); +} + +.tufte-highlight td:first-child { + font-weight: 600; +} + +/* Category Dots */ +.tufte-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 6px; + vertical-align: middle; +} + +/* Footnotes */ +.tufte-footnote { + font-size: 10px; + color: var(--color-gray-500); + margin-top: 8px; + font-style: italic; +} + +/* Footer */ +.tufte-footer { + margin-top: 32px; + padding-top: 12px; + border-top: 1px solid var(--color-gray-200); + font-size: 10px; + color: var(--color-gray-500); + text-align: center; +} + +/* Print-specific overrides */ +@media print { + @page { + size: A4; + margin: 15mm 18mm; + } + + /* Scoped to .print-body to avoid affecting other pages when printing */ + .print-body { + font-size: 10px; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + + .print-container { + max-width: none; + padding: 0; + } + + .tufte-section { + page-break-inside: auto; + } + + .tufte-section-title { + page-break-after: avoid; + } + + .tufte-table { + page-break-inside: auto; + } + + .tufte-table thead { + display: table-header-group; + } + + .tufte-table tr { + page-break-inside: avoid; + } + + .tufte-two-col { + page-break-inside: avoid; + } + + .tufte-keep-together { + page-break-inside: avoid; + } + + .tufte-header { + page-break-after: avoid; + } + + /* Force colors in print */ + .tufte-income { color: var(--color-green-700) !important; } + .tufte-expense { color: var(--color-red-700) !important; } + .tufte-up { color: var(--color-green-700) !important; } + .tufte-down { color: var(--color-red-700) !important; } + + .tufte-footer { + page-break-before: avoid; + } + + .tufte-highlight { + background: var(--color-yellow-100) !important; + } +} diff --git a/app/components/UI/account/activity_feed.html.erb b/app/components/UI/account/activity_feed.html.erb index f46053401..4c586a09d 100644 --- a/app/components/UI/account/activity_feed.html.erb +++ b/app/components/UI/account/activity_feed.html.erb @@ -46,6 +46,42 @@ "data-auto-submit-form-target": "auto" %> + + <%= render DS::Menu.new(variant: "button", no_padding: true) do |menu| %> + <% menu.with_button( + id: "activity-status-filter-button", + type: "button", + text: t("accounts.show.activity.filter"), + variant: "outline", + icon: "list-filter" + ) %> + + <% menu.with_custom_content do %> +
+

<%= t("accounts.show.activity.status") %>

+
+ <%= check_box_tag "q[status][]", + "confirmed", + params.dig(:q, :status)&.include?("confirmed"), + id: "q_status_confirmed", + class: "checkbox checkbox--light", + form: "entries-search", + onchange: "document.getElementById('entries-search').requestSubmit()" %> + <%= label_tag "q_status_confirmed", t("accounts.show.activity.confirmed"), class: "text-sm text-primary" %> +
+
+ <%= check_box_tag "q[status][]", + "pending", + params.dig(:q, :status)&.include?("pending"), + id: "q_status_pending", + class: "checkbox checkbox--light", + form: "entries-search", + onchange: "document.getElementById('entries-search').requestSubmit()" %> + <%= label_tag "q_status_pending", t("accounts.show.activity.pending"), class: "text-sm text-primary" %> +
+
+ <% end %> + <% end %> <% end %> <%= button_tag type: "button", id: "toggle-checkboxes-button", diff --git a/app/components/provider_sync_summary.html.erb b/app/components/provider_sync_summary.html.erb index 488a86004..184671c8e 100644 --- a/app/components/provider_sync_summary.html.erb +++ b/app/components/provider_sync_summary.html.erb @@ -69,6 +69,102 @@ <% end %> + <%# Pending→posted reconciliation %> + <% if has_pending_reconciled? %> +
+
+ <%= helpers.icon "check-circle", size: "sm", color: "success" %> + <%= t("provider_sync_summary.health.pending_reconciled", count: pending_reconciled) %> +
+ <% if pending_reconciled_details.any? %> +
+ + <%= t("provider_sync_summary.health.view_reconciled") %> + +
+ <% pending_reconciled_details.each do |detail| %> +

+ <%= detail["account_name"] %>: <%= detail["pending_name"] %> +

+ <% end %> +
+
+ <% end %> +
+ <% end %> + + <%# Duplicate suggestions needing review %> + <% if has_duplicate_suggestions_created? %> +
+
+ <%= helpers.icon "alert-triangle", size: "sm", color: "warning" %> + <%= t("provider_sync_summary.health.duplicate_suggestions", count: duplicate_suggestions_created) %> +
+ <% if duplicate_suggestions_details.any? %> +
+ + <%= t("provider_sync_summary.health.view_duplicate_suggestions") %> + +
+ <% duplicate_suggestions_details.each do |detail| %> +

+ <%= detail["account_name"] %>: <%= detail["pending_name"] %> → <%= detail["posted_name"] %> +

+ <% end %> +
+
+ <% end %> +
+ <% end %> + + <%# Stale pending transactions (auto-excluded) %> + <% if has_stale_pending? %> +
+
+ <%= helpers.icon "clock", size: "sm", color: "warning" %> + <%= t("provider_sync_summary.health.stale_pending", count: stale_pending_excluded) %> +
+ <% if stale_pending_details.any? %> +
+ + <%= t("provider_sync_summary.health.view_stale_pending") %> + +
+ <% stale_pending_details.each do |detail| %> +

+ <%= detail["account_name"] %>: <%= t("provider_sync_summary.health.stale_pending_count", count: detail["count"]) %> +

+ <% end %> +
+
+ <% end %> +
+ <% end %> + + <%# Stale unmatched pending (need manual review) %> + <% if has_stale_unmatched_pending? %> +
+
+ <%= helpers.icon "help-circle", size: "sm" %> + <%= t("provider_sync_summary.health.stale_unmatched", count: stale_unmatched_pending) %> +
+ <% if stale_unmatched_details.any? %> +
+ + <%= t("provider_sync_summary.health.view_stale_unmatched") %> + +
+ <% stale_unmatched_details.each do |detail| %> +

+ <%= detail["account_name"] %>: <%= t("provider_sync_summary.health.stale_unmatched_count", count: detail["count"]) %> +

+ <% end %> +
+
+ <% end %> +
+ <% end %> + <%# Data quality warnings %> <% if has_data_quality_issues? %>
diff --git a/app/components/provider_sync_summary.rb b/app/components/provider_sync_summary.rb index 4d00f2343..19d9aceb7 100644 --- a/app/components/provider_sync_summary.rb +++ b/app/components/provider_sync_summary.rb @@ -127,6 +127,58 @@ class ProviderSyncSummary < ViewComponent::Base total_errors > 0 end + # Stale pending transactions (auto-excluded) + def stale_pending_excluded + stats["stale_pending_excluded"].to_i + end + + def has_stale_pending? + stale_pending_excluded > 0 + end + + def stale_pending_details + stats["stale_pending_details"] || [] + end + + # Stale unmatched pending (need manual review - couldn't be automatically matched) + def stale_unmatched_pending + stats["stale_unmatched_pending"].to_i + end + + def has_stale_unmatched_pending? + stale_unmatched_pending > 0 + end + + def stale_unmatched_details + stats["stale_unmatched_details"] || [] + end + + # Pending→posted reconciliation stats + def pending_reconciled + stats["pending_reconciled"].to_i + end + + def has_pending_reconciled? + pending_reconciled > 0 + end + + def pending_reconciled_details + stats["pending_reconciled_details"] || [] + end + + # Duplicate suggestions needing user review + def duplicate_suggestions_created + stats["duplicate_suggestions_created"].to_i + end + + def has_duplicate_suggestions_created? + duplicate_suggestions_created > 0 + end + + def duplicate_suggestions_details + stats["duplicate_suggestions_details"] || [] + end + # Data quality / warnings def data_warnings stats["data_warnings"].to_i diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 48e484f40..1524b25d0 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -35,7 +35,7 @@ class AccountsController < ApplicationController def show @chart_view = params[:chart_view] || "balance" @tab = params[:tab] - @q = params.fetch(:q, {}).permit(:search) + @q = params.fetch(:q, {}).permit(:search, status: []) entries = @account.entries.search(@q).reverse_chronological @pagy, @entries = pagy(entries, limit: params[:per_page] || "10") diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb new file mode 100644 index 000000000..7e0252491 --- /dev/null +++ b/app/controllers/admin/base_controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Admin + class BaseController < ApplicationController + before_action :require_super_admin! + + layout "settings" + + private + def require_super_admin! + unless Current.user&.super_admin? + redirect_to root_path, alert: t("admin.unauthorized") + end + end + end +end diff --git a/app/controllers/admin/sso_providers_controller.rb b/app/controllers/admin/sso_providers_controller.rb new file mode 100644 index 000000000..97bc86fa3 --- /dev/null +++ b/app/controllers/admin/sso_providers_controller.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +module Admin + class SsoProvidersController < Admin::BaseController + before_action :set_sso_provider, only: %i[show edit update destroy toggle test_connection] + + def index + authorize SsoProvider + @sso_providers = policy_scope(SsoProvider).order(:name) + + # Load runtime providers (from YAML/env) that might not be in the database + # This helps show users that legacy providers are active but not manageable via UI + @runtime_providers = Rails.configuration.x.auth.sso_providers || [] + db_provider_names = @sso_providers.pluck(:name) + @legacy_providers = @runtime_providers.reject { |p| db_provider_names.include?(p[:name].to_s) } + end + + def show + authorize @sso_provider + end + + def new + @sso_provider = SsoProvider.new + authorize @sso_provider + end + + def create + @sso_provider = SsoProvider.new(processed_params) + authorize @sso_provider + + # Auto-generate redirect_uri if not provided + if @sso_provider.redirect_uri.blank? && @sso_provider.name.present? + @sso_provider.redirect_uri = "#{request.base_url}/auth/#{@sso_provider.name}/callback" + end + + if @sso_provider.save + log_provider_change(:create, @sso_provider) + clear_provider_cache + redirect_to admin_sso_providers_path, notice: t(".success") + else + render :new, status: :unprocessable_entity + end + end + + def edit + authorize @sso_provider + end + + def update + authorize @sso_provider + + # Auto-update redirect_uri if name changed + params_hash = processed_params.to_h + if params_hash[:name].present? && params_hash[:name] != @sso_provider.name + params_hash[:redirect_uri] = "#{request.base_url}/auth/#{params_hash[:name]}/callback" + end + + if @sso_provider.update(params_hash) + log_provider_change(:update, @sso_provider) + clear_provider_cache + redirect_to admin_sso_providers_path, notice: t(".success") + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + authorize @sso_provider + + @sso_provider.destroy! + log_provider_change(:destroy, @sso_provider) + clear_provider_cache + + redirect_to admin_sso_providers_path, notice: t(".success") + end + + def toggle + authorize @sso_provider + + @sso_provider.update!(enabled: !@sso_provider.enabled) + log_provider_change(:toggle, @sso_provider) + clear_provider_cache + + notice = @sso_provider.enabled? ? t(".success_enabled") : t(".success_disabled") + redirect_to admin_sso_providers_path, notice: notice + end + + def test_connection + authorize @sso_provider + + tester = SsoProviderTester.new(@sso_provider) + result = tester.test! + + render json: { + success: result.success?, + message: result.message, + details: result.details + } + end + + private + def set_sso_provider + @sso_provider = SsoProvider.find(params[:id]) + end + + def sso_provider_params + params.require(:sso_provider).permit( + :strategy, + :name, + :label, + :icon, + :enabled, + :issuer, + :client_id, + :client_secret, + :redirect_uri, + :scopes, + :prompt, + settings: [ + :default_role, :scopes, :prompt, + # SAML settings + :idp_metadata_url, :idp_sso_url, :idp_slo_url, + :idp_certificate, :idp_cert_fingerprint, :name_id_format, + role_mapping: {} + ] + ) + end + + # Process params to convert role_mapping comma-separated strings to arrays + def processed_params + result = sso_provider_params.to_h + + if result[:settings].present? && result[:settings][:role_mapping].present? + result[:settings][:role_mapping] = result[:settings][:role_mapping].transform_values do |v| + # Convert comma-separated string to array, removing empty values + v.to_s.split(",").map(&:strip).reject(&:blank?) + end + + # Remove empty role mappings + result[:settings][:role_mapping] = result[:settings][:role_mapping].reject { |_, v| v.empty? } + result[:settings].delete(:role_mapping) if result[:settings][:role_mapping].empty? + end + + result + end + + def log_provider_change(action, provider) + Rails.logger.info( + "[Admin::SsoProviders] #{action.to_s.upcase} - " \ + "user_id=#{Current.user.id} " \ + "provider_id=#{provider.id} " \ + "provider_name=#{provider.name} " \ + "strategy=#{provider.strategy} " \ + "enabled=#{provider.enabled}" + ) + end + + def clear_provider_cache + ProviderLoader.clear_cache + Rails.logger.info("[Admin::SsoProviders] Provider cache cleared by user_id=#{Current.user.id}") + end + end +end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb new file mode 100644 index 000000000..fdbc6e281 --- /dev/null +++ b/app/controllers/admin/users_controller.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Admin + class UsersController < Admin::BaseController + before_action :set_user, only: %i[update] + + def index + authorize User + @users = policy_scope(User).order(:email) + end + + def update + authorize @user + + if @user.update(user_params) + Rails.logger.info( + "[Admin::Users] Role changed - " \ + "by_user_id=#{Current.user.id} " \ + "target_user_id=#{@user.id} " \ + "new_role=#{@user.role}" + ) + redirect_to admin_users_path, notice: t(".success") + else + redirect_to admin_users_path, alert: t(".failure") + end + end + + private + + def set_user + @user = User.find(params[:id]) + end + + def user_params + params.require(:user).permit(:role) + end + end +end diff --git a/app/controllers/api/v1/chats_controller.rb b/app/controllers/api/v1/chats_controller.rb index 87094c26d..ad1a2b228 100644 --- a/app/controllers/api/v1/chats_controller.rb +++ b/app/controllers/api/v1/chats_controller.rb @@ -28,7 +28,12 @@ class Api::V1::ChatsController < Api::V1::BaseController ) if @message.save - AssistantResponseJob.perform_later(@message) + # NOTE: Commenting out duplicate job enqueue to fix mobile app receiving duplicate AI responses + # UserMessage model already triggers AssistantResponseJob via after_create_commit callback + # in app/models/user_message.rb:10-12, so this manual enqueue causes the job to run twice, + # resulting in duplicate AI responses with different content and wasted tokens. + # See: https://github.com/dwvwdv/sure (mobile app integration issue) + # AssistantResponseJob.perform_later(@message) render :show, status: :created else @chat.destroy diff --git a/app/controllers/api/v1/merchants_controller.rb b/app/controllers/api/v1/merchants_controller.rb new file mode 100644 index 000000000..53df0ac35 --- /dev/null +++ b/app/controllers/api/v1/merchants_controller.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module Api + module V1 + # API v1 endpoint for merchants + # Provides read-only access to family and provider merchants + # + # @example List all merchants + # GET /api/v1/merchants + # + # @example Get a specific merchant + # GET /api/v1/merchants/:id + # + class MerchantsController < BaseController + before_action :ensure_read_scope + + # List all merchants available to the family + # + # Returns both family-owned merchants and provider merchants + # that are assigned to the family's transactions. + # + # @return [Array] JSON array of merchant objects + def index + family = current_resource_owner.family + + # Single query with OR conditions - more efficient than Ruby deduplication + family_merchant_ids = family.merchants.select(:id) + provider_merchant_ids = family.transactions.select(:merchant_id) + + @merchants = Merchant + .where(id: family_merchant_ids) + .or(Merchant.where(id: provider_merchant_ids, type: "ProviderMerchant")) + .distinct + .alphabetically + + render json: @merchants.map { |m| merchant_json(m) } + rescue StandardError => e + Rails.logger.error("API Merchants Error: #{e.message}") + render json: { error: "Failed to fetch merchants" }, status: :internal_server_error + end + + # Get a specific merchant by ID + # + # Returns a merchant if it belongs to the family or is assigned + # to any of the family's transactions. + # + # @param id [String] The merchant ID + # @return [Hash] JSON merchant object or error + def show + family = current_resource_owner.family + + @merchant = family.merchants.find_by(id: params[:id]) || + Merchant.joins(:transactions) + .where(transactions: { account_id: family.accounts.select(:id) }) + .distinct + .find_by(id: params[:id]) + + if @merchant + render json: merchant_json(@merchant) + else + render json: { error: "Merchant not found" }, status: :not_found + end + rescue StandardError => e + Rails.logger.error("API Merchant Show Error: #{e.message}") + render json: { error: "Failed to fetch merchant" }, status: :internal_server_error + end + + private + + # Serialize a merchant to JSON format + # + # @param merchant [Merchant] The merchant to serialize + # @return [Hash] JSON-serializable hash + def merchant_json(merchant) + { + id: merchant.id, + name: merchant.name, + type: merchant.type, + created_at: merchant.created_at, + updated_at: merchant.updated_at + } + end + end + end +end diff --git a/app/controllers/api/v1/messages_controller.rb b/app/controllers/api/v1/messages_controller.rb index 305ee09df..f9f3b8388 100644 --- a/app/controllers/api/v1/messages_controller.rb +++ b/app/controllers/api/v1/messages_controller.rb @@ -13,7 +13,12 @@ class Api::V1::MessagesController < Api::V1::BaseController ) if @message.save - AssistantResponseJob.perform_later(@message) + # NOTE: Commenting out duplicate job enqueue to fix mobile app receiving duplicate AI responses + # UserMessage model already triggers AssistantResponseJob via after_create_commit callback + # in app/models/user_message.rb:10-12, so this manual enqueue causes the job to run twice, + # resulting in duplicate AI responses with different content and wasted tokens. + # See: https://github.com/dwvwdv/sure (mobile app integration issue) + # AssistantResponseJob.perform_later(@message) render :show, status: :created else render json: { error: "Failed to create message", details: @message.errors.full_messages }, status: :unprocessable_entity diff --git a/app/controllers/api/v1/tags_controller.rb b/app/controllers/api/v1/tags_controller.rb new file mode 100644 index 000000000..287642930 --- /dev/null +++ b/app/controllers/api/v1/tags_controller.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +module Api + module V1 + # API v1 endpoint for tags + # Provides full CRUD operations for family tags + # + # @example List all tags + # GET /api/v1/tags + # + # @example Create a new tag + # POST /api/v1/tags + # { "tag": { "name": "WhiteHouse", "color": "#3b82f6" } } + # + class TagsController < BaseController + before_action :ensure_read_scope, only: %i[index show] + before_action :ensure_write_scope, only: %i[create update destroy] + before_action :set_tag, only: %i[show update destroy] + + # List all tags belonging to the family + # + # @return [Array] JSON array of tag objects sorted alphabetically + def index + family = current_resource_owner.family + @tags = family.tags.alphabetically + + render json: @tags.map { |t| tag_json(t) } + rescue StandardError => e + Rails.logger.error("API Tags Error: #{e.message}") + render json: { error: "Failed to fetch tags" }, status: :internal_server_error + end + + # Get a specific tag by ID + # + # @param id [String] The tag ID + # @return [Hash] JSON tag object + def show + render json: tag_json(@tag) + rescue StandardError => e + Rails.logger.error("API Tag Show Error: #{e.message}") + render json: { error: "Failed to fetch tag" }, status: :internal_server_error + end + + # Create a new tag for the family + # + # @param name [String] Tag name (required) + # @param color [String] Hex color code (optional, auto-assigned if not provided) + # @return [Hash] JSON tag object with status 201 + def create + family = current_resource_owner.family + @tag = family.tags.new(tag_params) + + # Assign random color if not provided + @tag.color ||= Tag::COLORS.sample + + if @tag.save + render json: tag_json(@tag), status: :created + else + render json: { error: @tag.errors.full_messages.join(", ") }, status: :unprocessable_entity + end + rescue StandardError => e + Rails.logger.error("API Tag Create Error: #{e.message}") + render json: { error: "Failed to create tag" }, status: :internal_server_error + end + + # Update an existing tag + # + # @param id [String] The tag ID + # @param name [String] New tag name (optional) + # @param color [String] New hex color code (optional) + # @return [Hash] JSON tag object + def update + if @tag.update(tag_params) + render json: tag_json(@tag) + else + render json: { error: @tag.errors.full_messages.join(", ") }, status: :unprocessable_entity + end + rescue StandardError => e + Rails.logger.error("API Tag Update Error: #{e.message}") + render json: { error: "Failed to update tag" }, status: :internal_server_error + end + + # Delete a tag + # + # @param id [String] The tag ID + # @return [nil] Empty response with status 204 + def destroy + @tag.destroy! + head :no_content + rescue StandardError => e + Rails.logger.error("API Tag Destroy Error: #{e.message}") + render json: { error: "Failed to delete tag" }, status: :internal_server_error + end + + private + + # Find and set the tag from params + # + # @raise [ActiveRecord::RecordNotFound] if tag not found + # @return [Tag] The found tag + def set_tag + family = current_resource_owner.family + @tag = family.tags.find(params[:id]) + rescue ActiveRecord::RecordNotFound + render json: { error: "Tag not found" }, status: :not_found + end + + # Strong parameters for tag creation/update + # + # @return [ActionController::Parameters] Permitted parameters + def tag_params + params.require(:tag).permit(:name, :color) + end + + # Serialize a tag to JSON format + # + # @param tag [Tag] The tag to serialize + # @return [Hash] JSON-serializable hash + def tag_json(tag) + { + id: tag.id, + name: tag.name, + color: tag.color, + created_at: tag.created_at, + updated_at: tag.updated_at + } + end + end + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index d63adfa8a..b171a080d 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -2,9 +2,15 @@ class ApplicationController < ActionController::Base include RestoreLayoutPreferences, Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable, Breadcrumbable, FeatureGuardable, Notifiable + include Pundit::Authorization include Pagy::Backend + # Pundit uses current_user by default, but this app uses Current.user + def pundit_user + Current.user + end + before_action :detect_os before_action :set_default_chat before_action :set_active_storage_url_options diff --git a/app/controllers/holdings_controller.rb b/app/controllers/holdings_controller.rb index 96ce2cd49..cd6d6260c 100644 --- a/app/controllers/holdings_controller.rb +++ b/app/controllers/holdings_controller.rb @@ -1,5 +1,5 @@ class HoldingsController < ApplicationController - before_action :set_holding, only: %i[show destroy] + before_action :set_holding, only: %i[show update destroy unlock_cost_basis] def index @account = Current.family.accounts.find(params[:account_id]) @@ -8,6 +8,31 @@ class HoldingsController < ApplicationController def show end + def update + total_cost_basis = holding_params[:cost_basis].to_d + + if total_cost_basis >= 0 && @holding.qty.positive? + # Convert total cost basis to per-share cost (the cost_basis field stores per-share) + # Zero is valid for gifted/inherited shares + per_share_cost = total_cost_basis / @holding.qty + @holding.set_manual_cost_basis!(per_share_cost) + flash[:notice] = t(".success") + else + flash[:alert] = t(".error") + end + + # Redirect to account page holdings tab to refresh list and close drawer + redirect_to account_path(@holding.account, tab: "holdings") + end + + def unlock_cost_basis + @holding.unlock_cost_basis! + flash[:notice] = t(".success") + + # Redirect to account page holdings tab to refresh list and close drawer + redirect_to account_path(@holding.account, tab: "holdings") + end + def destroy if @holding.account.can_delete_holdings? @holding.destroy_holding_and_entries! @@ -26,4 +51,8 @@ class HoldingsController < ApplicationController def set_holding @holding = Current.family.holdings.find(params[:id]) end + + def holding_params + params.require(:holding).permit(:cost_basis) + end end diff --git a/app/controllers/oidc_accounts_controller.rb b/app/controllers/oidc_accounts_controller.rb index c85c4dbec..6e22a1c93 100644 --- a/app/controllers/oidc_accounts_controller.rb +++ b/app/controllers/oidc_accounts_controller.rb @@ -37,6 +37,13 @@ class OidcAccountsController < ApplicationController user ) + # Log account linking + SsoAuditLog.log_link!( + user: user, + provider: @pending_auth["provider"], + request: request + ) + # Clear pending auth from session session.delete(:pending_oidc_auth) @@ -104,15 +111,28 @@ class OidcAccountsController < ApplicationController # Create new family for this user @user.family = Family.new - @user.role = :admin + + # Use provider-configured default role, or fall back to member (not admin) + provider_config = Rails.configuration.x.auth.sso_providers&.find { |p| p[:name] == @pending_auth["provider"] } + default_role = provider_config&.dig(:settings, :default_role) || "member" + @user.role = default_role if @user.save # Create the OIDC (or other SSO) identity - OidcIdentity.create_from_omniauth( + identity = OidcIdentity.create_from_omniauth( build_auth_hash(@pending_auth), @user ) + # Only log JIT account creation if identity was successfully created + if identity.persisted? + SsoAuditLog.log_jit_account_created!( + user: @user, + provider: @pending_auth["provider"], + request: request + ) + end + # Clear pending auth from session session.delete(:pending_oidc_auth) diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb index bfb833acb..91593f801 100644 --- a/app/controllers/reports_controller.rb +++ b/app/controllers/reports_controller.rb @@ -7,38 +7,7 @@ class ReportsController < ApplicationController before_action :authenticate_for_export, only: :export_transactions def index - @period_type = params[:period_type]&.to_sym || :monthly - @start_date = parse_date_param(:start_date) || default_start_date - @end_date = parse_date_param(:end_date) || default_end_date - - # Validate and fix date range if end_date is before start_date - validate_and_fix_date_range(show_flash: true) - - # Build the period - @period = Period.custom(start_date: @start_date, end_date: @end_date) - @previous_period = build_previous_period - - # Get aggregated data - @current_income_totals = Current.family.income_statement.income_totals(period: @period) - @current_expense_totals = Current.family.income_statement.expense_totals(period: @period) - - @previous_income_totals = Current.family.income_statement.income_totals(period: @previous_period) - @previous_expense_totals = Current.family.income_statement.expense_totals(period: @previous_period) - - # Calculate summary metrics - @summary_metrics = build_summary_metrics - - # Build trend data (last 6 months) - @trends_data = build_trends_data - - # Net worth metrics - @net_worth_metrics = build_net_worth_metrics - - # Transactions breakdown - @transactions = build_transactions_breakdown - - # Investment metrics (must be before build_reports_sections) - @investment_metrics = build_investment_metrics + setup_report_data(show_flash: true) # Build reports sections for collapsible/reorderable UI @reports_sections = build_reports_sections @@ -46,6 +15,12 @@ class ReportsController < ApplicationController @breadcrumbs = [ [ "Home", root_path ], [ "Reports", nil ] ] end + def print + setup_report_data(show_flash: false) + + render layout: "print" + end + def update_preferences if Current.user.update_reports_preferences(preferences_params) head :ok @@ -114,6 +89,44 @@ class ReportsController < ApplicationController end private + def setup_report_data(show_flash: false) + @period_type = params[:period_type]&.to_sym || :monthly + @start_date = parse_date_param(:start_date) || default_start_date + @end_date = parse_date_param(:end_date) || default_end_date + + # Validate and fix date range if end_date is before start_date + validate_and_fix_date_range(show_flash: show_flash) + + # Build the period + @period = Period.custom(start_date: @start_date, end_date: @end_date) + @previous_period = build_previous_period + + # Get aggregated data + @current_income_totals = Current.family.income_statement.income_totals(period: @period) + @current_expense_totals = Current.family.income_statement.expense_totals(period: @period) + + @previous_income_totals = Current.family.income_statement.income_totals(period: @previous_period) + @previous_expense_totals = Current.family.income_statement.expense_totals(period: @previous_period) + + # Calculate summary metrics + @summary_metrics = build_summary_metrics + + # Build trend data (last 6 months) + @trends_data = build_trends_data + + # Net worth metrics + @net_worth_metrics = build_net_worth_metrics + + # Transactions breakdown + @transactions = build_transactions_breakdown + + # Investment metrics + @investment_metrics = build_investment_metrics + + # Flags for view rendering + @has_accounts = Current.family.accounts.any? + end + def preferences_params prefs = params.require(:preferences) {}.tap do |permitted| diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index f42fac451..2d9007668 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,9 +1,14 @@ class SessionsController < ApplicationController before_action :set_session, only: :destroy - skip_authentication only: %i[new create openid_connect failure] + skip_authentication only: %i[index new create openid_connect failure post_logout] layout "auth" + # Handle GET /sessions (usually from browser back button) + def index + redirect_to new_session_path + end + def new begin demo = Rails.application.config_for(:demo) @@ -62,7 +67,40 @@ class SessionsController < ApplicationController end def destroy + user = Current.user + id_token = session[:id_token_hint] + login_provider = session[:sso_login_provider] + + # Find the identity for the provider used during login, with fallback to first if session data lost + oidc_identity = if login_provider.present? + user.oidc_identities.find_by(provider: login_provider) + else + user.oidc_identities.first + end + + # Destroy local session @session.destroy + session.delete(:id_token_hint) + session.delete(:sso_login_provider) + + # Check if we should redirect to IdP for federated logout + if oidc_identity && id_token.present? + idp_logout_url = build_idp_logout_url(oidc_identity, id_token) + + if idp_logout_url + SsoAuditLog.log_logout_idp!(user: user, provider: oidc_identity.provider, request: request) + redirect_to idp_logout_url, allow_other_host: true + return + end + end + + # Standard local logout + SsoAuditLog.log_logout!(user: user, request: request) + redirect_to new_session_path, notice: t(".logout_successful") + end + + # Handle redirect back from IdP after federated logout + def post_logout redirect_to new_session_path, notice: t(".logout_successful") end @@ -82,6 +120,14 @@ class SessionsController < ApplicationController # Existing OIDC identity found - authenticate the user user = oidc_identity.user oidc_identity.record_authentication! + oidc_identity.sync_user_attributes!(auth) + + # Store id_token and provider for RP-initiated logout + session[:id_token_hint] = auth.credentials&.id_token if auth.credentials&.id_token + session[:sso_login_provider] = auth.provider + + # Log successful SSO login + SsoAuditLog.log_login!(user: user, provider: auth.provider, request: request) # MFA check: If user has MFA enabled, require verification if user.otp_required? @@ -107,7 +153,27 @@ class SessionsController < ApplicationController end def failure - redirect_to new_session_path, alert: t("sessions.failure.failed") + # Sanitize reason to known values only + known_reasons = %w[sso_provider_unavailable sso_invalid_response sso_failed] + sanitized_reason = known_reasons.include?(params[:message]) ? params[:message] : "sso_failed" + + # Log failed SSO attempt + SsoAuditLog.log_login_failed!( + provider: params[:strategy], + request: request, + reason: sanitized_reason + ) + + message = case sanitized_reason + when "sso_provider_unavailable" + t("sessions.failure.sso_provider_unavailable") + when "sso_invalid_response" + t("sessions.failure.sso_invalid_response") + else + t("sessions.failure.sso_failed") + end + + redirect_to new_session_path, alert: message end private @@ -130,4 +196,53 @@ class SessionsController < ApplicationController demo["hosts"].include?(request.host) end + + def build_idp_logout_url(oidc_identity, id_token) + # Find the provider configuration using unified loader (supports both YAML and DB providers) + provider_config = ProviderLoader.load_providers.find do |p| + p[:name] == oidc_identity.provider + end + + return nil unless provider_config + + # For OIDC providers, fetch end_session_endpoint from discovery + if provider_config[:strategy] == "openid_connect" && provider_config[:issuer].present? + begin + discovery_url = discovery_url_for(provider_config[:issuer]) + response = Faraday.get(discovery_url) do |req| + req.options.timeout = 5 + req.options.open_timeout = 3 + end + + return nil unless response.success? + + discovery = JSON.parse(response.body) + end_session_endpoint = discovery["end_session_endpoint"] + + return nil unless end_session_endpoint.present? + + # Build the logout URL with post_logout_redirect_uri + post_logout_redirect = "#{request.base_url}/auth/logout/callback" + params = { + id_token_hint: id_token, + post_logout_redirect_uri: post_logout_redirect + } + + "#{end_session_endpoint}?#{params.to_query}" + rescue Faraday::Error, JSON::ParserError, StandardError => e + Rails.logger.warn("[SSO] Failed to fetch OIDC discovery for logout: #{e.message}") + nil + end + else + nil + end + end + + def discovery_url_for(issuer) + if issuer.end_with?("/") + "#{issuer}.well-known/openid-configuration" + else + "#{issuer}/.well-known/openid-configuration" + end + end end diff --git a/app/controllers/settings/hostings_controller.rb b/app/controllers/settings/hostings_controller.rb index 9300a0b2c..dd4024d7b 100644 --- a/app/controllers/settings/hostings_controller.rb +++ b/app/controllers/settings/hostings_controller.rb @@ -58,6 +58,33 @@ class Settings::HostingsController < ApplicationController Setting.securities_provider = hosting_params[:securities_provider] end + if hosting_params.key?(:syncs_include_pending) + Setting.syncs_include_pending = hosting_params[:syncs_include_pending] == "1" + end + + sync_settings_changed = false + + if hosting_params.key?(:auto_sync_enabled) + Setting.auto_sync_enabled = hosting_params[:auto_sync_enabled] == "1" + sync_settings_changed = true + end + + if hosting_params.key?(:auto_sync_time) + time_value = hosting_params[:auto_sync_time] + unless Setting.valid_auto_sync_time?(time_value) + flash[:alert] = t(".invalid_sync_time") + return redirect_to settings_hosting_path + end + + Setting.auto_sync_time = time_value + Setting.auto_sync_timezone = current_user_timezone + sync_settings_changed = true + end + + if sync_settings_changed + sync_auto_sync_scheduler! + end + if hosting_params.key?(:openai_access_token) token_param = hosting_params[:openai_access_token].to_s.strip # Ignore blanks and redaction placeholders to prevent accidental overwrite @@ -99,10 +126,22 @@ class Settings::HostingsController < ApplicationController private def hosting_params - params.require(:setting).permit(:onboarding_state, :require_email_confirmation, :brand_fetch_client_id, :twelve_data_api_key, :openai_access_token, :openai_uri_base, :openai_model, :openai_json_mode, :exchange_rate_provider, :securities_provider) + params.require(:setting).permit(:onboarding_state, :require_email_confirmation, :brand_fetch_client_id, :twelve_data_api_key, :openai_access_token, :openai_uri_base, :openai_model, :openai_json_mode, :exchange_rate_provider, :securities_provider, :syncs_include_pending, :auto_sync_enabled, :auto_sync_time) end def ensure_admin redirect_to settings_hosting_path, alert: t(".not_authorized") unless Current.user.admin? end + + def sync_auto_sync_scheduler! + AutoSyncScheduler.sync! + rescue StandardError => error + Rails.logger.error("[AutoSyncScheduler] Failed to sync scheduler: #{error.message}") + Rails.logger.error(error.backtrace.join("\n")) + flash[:alert] = t(".scheduler_sync_failed") + end + + def current_user_timezone + Current.family&.timezone.presence || "UTC" + end end diff --git a/app/controllers/settings/securities_controller.rb b/app/controllers/settings/securities_controller.rb index 756accf79..fd6791994 100644 --- a/app/controllers/settings/securities_controller.rb +++ b/app/controllers/settings/securities_controller.rb @@ -6,5 +6,6 @@ class Settings::SecuritiesController < ApplicationController [ "Home", root_path ], [ "Security", nil ] ] + @oidc_identities = Current.user.oidc_identities.order(:provider) end end diff --git a/app/controllers/settings/sso_identities_controller.rb b/app/controllers/settings/sso_identities_controller.rb new file mode 100644 index 000000000..97f946a90 --- /dev/null +++ b/app/controllers/settings/sso_identities_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class Settings::SsoIdentitiesController < ApplicationController + layout "settings" + + def destroy + @identity = Current.user.oidc_identities.find(params[:id]) + + # Prevent unlinking last identity if user has no password + if Current.user.oidc_identities.count == 1 && Current.user.password_digest.blank? + redirect_to settings_security_path, alert: t(".cannot_unlink_last") + return + end + + provider_name = @identity.provider + @identity.destroy! + + # Log account unlinking + SsoAuditLog.log_unlink!( + user: Current.user, + provider: provider_name, + request: request + ) + + redirect_to settings_security_path, notice: t(".success", provider: provider_name) + end +end diff --git a/app/controllers/transaction_categories_controller.rb b/app/controllers/transaction_categories_controller.rb index 3fe8207dc..d8fc64e96 100644 --- a/app/controllers/transaction_categories_controller.rb +++ b/app/controllers/transaction_categories_controller.rb @@ -24,9 +24,14 @@ class TransactionCategoriesController < ApplicationController format.turbo_stream do render turbo_stream: [ turbo_stream.replace( - dom_id(transaction, :category_menu), - partial: "categories/menu", - locals: { transaction: transaction } + dom_id(transaction, "category_menu_mobile"), + partial: "transactions/transaction_category", + locals: { transaction: transaction, variant: "mobile" } + ), + turbo_stream.replace( + dom_id(transaction, "category_menu_desktop"), + partial: "transactions/transaction_category", + locals: { transaction: transaction, variant: "desktop" } ), turbo_stream.replace( "category_name_mobile_#{transaction.id}", diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index 07e829ebd..753b77300 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -116,6 +116,38 @@ class TransactionsController < ApplicationController end end + def merge_duplicate + transaction = Current.family.transactions.includes(entry: :account).find(params[:id]) + + if transaction.merge_with_duplicate! + flash[:notice] = t("transactions.merge_duplicate.success") + else + flash[:alert] = t("transactions.merge_duplicate.failure") + end + + redirect_to transactions_path + rescue ActiveRecord::RecordNotDestroyed, ActiveRecord::RecordInvalid => e + Rails.logger.error("Failed to merge duplicate transaction #{params[:id]}: #{e.message}") + flash[:alert] = t("transactions.merge_duplicate.failure") + redirect_to transactions_path + end + + def dismiss_duplicate + transaction = Current.family.transactions.includes(entry: :account).find(params[:id]) + + if transaction.dismiss_duplicate_suggestion! + flash[:notice] = t("transactions.dismiss_duplicate.success") + else + flash[:alert] = t("transactions.dismiss_duplicate.failure") + end + + redirect_back_or_to transactions_path + rescue ActiveRecord::RecordInvalid => e + Rails.logger.error("Failed to dismiss duplicate suggestion for transaction #{params[:id]}: #{e.message}") + flash[:alert] = t("transactions.dismiss_duplicate.failure") + redirect_back_or_to transactions_path + end + def mark_as_recurring transaction = Current.family.transactions.includes(entry: :account).find(params[:id]) @@ -186,7 +218,7 @@ class TransactionsController < ApplicationController def entry_params entry_params = params.require(:entry).permit( :name, :date, :amount, :currency, :excluded, :notes, :nature, :entryable_type, - entryable_attributes: [ :id, :category_id, :merchant_id, :kind, { tag_ids: [] } ] + entryable_attributes: [ :id, :category_id, :merchant_id, :kind, :investment_activity_label, { tag_ids: [] } ] ) nature = entry_params.delete(:nature) @@ -205,7 +237,7 @@ class TransactionsController < ApplicationController :start_date, :end_date, :search, :amount, :amount_operator, :active_accounts_only, accounts: [], account_ids: [], - categories: [], merchants: [], types: [], tags: [] + categories: [], merchants: [], types: [], tags: [], status: [] ) .to_h .compact_blank diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb index 98c35f4c1..65fb550c3 100644 --- a/app/helpers/languages_helper.rb +++ b/app/helpers/languages_helper.rb @@ -58,7 +58,7 @@ module LanguagesHelper uk: "Ukrainian", vi: "Vietnamese", 'zh-CN': "简体中文", - 'zh-TW': "Chinese (Traditional)", + 'zh-TW': "繁體中文", af: "Afrikaans", az: "Azerbaijani", be: "Belarusian", @@ -163,7 +163,8 @@ module LanguagesHelper "ca", # Catalan - 57 translation files "ro", # Romanian - 62 translation files "pt-BR", # Brazilian Portuguese - 60 translation files - "zh-CN" # Chinese (Simplified) - 59 translation files + "zh-CN", # Chinese (Simplified) - 59 translation files + "zh-TW" # Chinese (Traditional) - 63 translation files ].freeze COUNTRY_MAPPING = { diff --git a/app/helpers/reports_helper.rb b/app/helpers/reports_helper.rb new file mode 100644 index 000000000..52b3016b5 --- /dev/null +++ b/app/helpers/reports_helper.rb @@ -0,0 +1,34 @@ +module ReportsHelper + # Generate SVG polyline points for a sparkline chart + # Returns empty string if fewer than 2 data points (can't draw a line with 1 point) + def sparkline_points(values, width: 60, height: 16) + return "" if values.nil? || values.length < 2 || values.all? { |v| v.nil? || v.zero? } + + nums = values.map(&:to_f) + max_val = nums.max + min_val = nums.min + range = max_val - min_val + range = 1.0 if range.zero? + + points = nums.each_with_index.map do |val, i| + x = (i.to_f / [ nums.length - 1, 1 ].max) * width + y = height - ((val - min_val) / range * (height - 2)) - 1 + "#{x.round(1)},#{y.round(1)}" + end + + points.join(" ") + end + + # Calculate cumulative net values from trends data + def cumulative_net_values(trends) + return [] if trends.nil? + + running = 0 + trends.map { |t| running += t[:net].to_i; running } + end + + # Check if trends data has enough points for sparklines (need at least 2) + def has_sparkline_data?(trends_data) + trends_data&.length.to_i >= 2 + end +end diff --git a/app/helpers/transactions_helper.rb b/app/helpers/transactions_helper.rb index 024d742be..92ce26c81 100644 --- a/app/helpers/transactions_helper.rb +++ b/app/helpers/transactions_helper.rb @@ -1,13 +1,14 @@ module TransactionsHelper def transaction_search_filters [ - { key: "account_filter", label: "Account", icon: "layers" }, - { key: "date_filter", label: "Date", icon: "calendar" }, - { key: "type_filter", label: "Type", icon: "tag" }, - { key: "amount_filter", label: "Amount", icon: "hash" }, - { key: "category_filter", label: "Category", icon: "shapes" }, - { key: "tag_filter", label: "Tag", icon: "tags" }, - { key: "merchant_filter", label: "Merchant", icon: "store" } + { key: "account_filter", label: t("transactions.search.filters.account"), icon: "layers" }, + { key: "date_filter", label: t("transactions.search.filters.date"), icon: "calendar" }, + { key: "type_filter", label: t("transactions.search.filters.type"), icon: "tag" }, + { key: "status_filter", label: t("transactions.search.filters.status"), icon: "clock" }, + { key: "amount_filter", label: t("transactions.search.filters.amount"), icon: "hash" }, + { key: "category_filter", label: t("transactions.search.filters.category"), icon: "shapes" }, + { key: "tag_filter", label: t("transactions.search.filters.tag"), icon: "tags" }, + { key: "merchant_filter", label: t("transactions.search.filters.merchant"), icon: "store" } ] end diff --git a/app/javascript/controllers/admin_sso_form_controller.js b/app/javascript/controllers/admin_sso_form_controller.js new file mode 100644 index 000000000..2344b8b63 --- /dev/null +++ b/app/javascript/controllers/admin_sso_form_controller.js @@ -0,0 +1,226 @@ +import { Controller } from "@hotwired/stimulus" + +// Connects to data-controller="admin-sso-form" +export default class extends Controller { + static targets = ["callbackUrl", "testResult", "samlCallbackUrl"] + + connect() { + // Initialize field visibility on page load + this.toggleFields() + // Initialize callback URL + this.updateCallbackUrl() + } + + updateCallbackUrl() { + const nameInput = this.element.querySelector('input[name*="[name]"]') + const callbackDisplay = this.callbackUrlTarget + + if (!nameInput || !callbackDisplay) return + + const providerName = nameInput.value.trim() || 'PROVIDER_NAME' + const baseUrl = window.location.origin + callbackDisplay.textContent = `${baseUrl}/auth/${providerName}/callback` + } + + toggleFields() { + const strategySelect = this.element.querySelector('select[name*="[strategy]"]') + if (!strategySelect) return + + const strategy = strategySelect.value + const isOidc = strategy === "openid_connect" + const isSaml = strategy === "saml" + + // Toggle OIDC fields + const oidcFields = this.element.querySelectorAll('[data-oidc-field]') + oidcFields.forEach(field => { + if (isOidc) { + field.classList.remove('hidden') + } else { + field.classList.add('hidden') + } + }) + + // Toggle SAML fields + const samlFields = this.element.querySelectorAll('[data-saml-field]') + samlFields.forEach(field => { + if (isSaml) { + field.classList.remove('hidden') + } else { + field.classList.add('hidden') + } + }) + + // Update SAML callback URL if present + if (this.hasSamlCallbackUrlTarget) { + this.updateSamlCallbackUrl() + } + } + + updateSamlCallbackUrl() { + const nameInput = this.element.querySelector('input[name*="[name]"]') + if (!nameInput || !this.hasSamlCallbackUrlTarget) return + + const providerName = nameInput.value.trim() || 'PROVIDER_NAME' + const baseUrl = window.location.origin + this.samlCallbackUrlTarget.textContent = `${baseUrl}/auth/${providerName}/callback` + } + + copySamlCallback(event) { + event.preventDefault() + + if (!this.hasSamlCallbackUrlTarget) return + + const callbackUrl = this.samlCallbackUrlTarget.textContent + + navigator.clipboard.writeText(callbackUrl).then(() => { + const button = event.currentTarget + const originalText = button.innerHTML + button.innerHTML = ' Copied!' + button.classList.add('text-green-600') + + setTimeout(() => { + button.innerHTML = originalText + button.classList.remove('text-green-600') + }, 2000) + }).catch(err => { + console.error('Failed to copy:', err) + alert('Failed to copy to clipboard') + }) + } + + async validateIssuer(event) { + const issuerInput = event.target + const issuer = issuerInput.value.trim() + + if (!issuer) return + + try { + // Construct discovery URL + const discoveryUrl = issuer.endsWith('/') + ? `${issuer}.well-known/openid-configuration` + : `${issuer}/.well-known/openid-configuration` + + // Show loading state + issuerInput.classList.add('border-yellow-300') + + const response = await fetch(discoveryUrl, { + method: 'GET', + headers: { 'Accept': 'application/json' } + }) + + if (response.ok) { + const data = await response.json() + if (data.issuer) { + // Valid OIDC discovery endpoint + issuerInput.classList.remove('border-yellow-300', 'border-red-300') + issuerInput.classList.add('border-green-300') + this.showValidationMessage(issuerInput, 'Valid OIDC issuer', 'success') + } else { + throw new Error('Invalid discovery response') + } + } else { + throw new Error(`Discovery endpoint returned ${response.status}`) + } + } catch (error) { + // CORS errors are expected when validating from browser - show as warning not error + issuerInput.classList.remove('border-yellow-300', 'border-green-300') + issuerInput.classList.add('border-amber-300') + this.showValidationMessage(issuerInput, "Could not validate from browser (CORS). Provider can still be saved.", 'warning') + } + } + + copyCallback(event) { + event.preventDefault() + + const callbackDisplay = this.callbackUrlTarget + if (!callbackDisplay) return + + const callbackUrl = callbackDisplay.textContent + + // Copy to clipboard + navigator.clipboard.writeText(callbackUrl).then(() => { + // Show success feedback + const button = event.currentTarget + const originalText = button.innerHTML + button.innerHTML = ' Copied!' + button.classList.add('text-green-600') + + setTimeout(() => { + button.innerHTML = originalText + button.classList.remove('text-green-600') + }, 2000) + }).catch(err => { + console.error('Failed to copy:', err) + alert('Failed to copy to clipboard') + }) + } + + showValidationMessage(input, message, type) { + // Remove any existing validation message + const existingMessage = input.parentElement.querySelector('.validation-message') + if (existingMessage) { + existingMessage.remove() + } + + // Create new validation message + const messageEl = document.createElement('p') + const colorClass = type === 'success' ? 'text-green-600' : type === 'warning' ? 'text-amber-600' : 'text-red-600' + messageEl.className = `validation-message mt-1 text-sm ${colorClass}` + messageEl.textContent = message + + input.parentElement.appendChild(messageEl) + + // Auto-remove after 5 seconds (except warnings which stay) + if (type !== 'warning') { + setTimeout(() => { + messageEl.remove() + input.classList.remove('border-green-300', 'border-red-300', 'border-amber-300') + }, 5000) + } + } + + async testConnection(event) { + const button = event.currentTarget + const testUrl = button.dataset.adminSsoFormTestUrlValue + const resultEl = this.testResultTarget + + if (!testUrl) return + + // Show loading state + button.disabled = true + button.textContent = 'Testing...' + resultEl.textContent = '' + resultEl.className = 'ml-2 text-sm' + + try { + const response = await fetch(testUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content + } + }) + + const data = await response.json() + + if (data.success) { + resultEl.textContent = `✓ ${data.message}` + resultEl.classList.add('text-green-600') + } else { + resultEl.textContent = `✗ ${data.message}` + resultEl.classList.add('text-red-600') + } + + // Show details in console for debugging + if (data.details && Object.keys(data.details).length > 0) { + console.log('SSO Test Connection Details:', data.details) + } + } catch (error) { + resultEl.textContent = `✗ Request failed: ${error.message}` + resultEl.classList.add('text-red-600') + } finally { + button.disabled = false + button.textContent = 'Test Connection' + } + } +} diff --git a/app/javascript/controllers/cost_basis_form_controller.js b/app/javascript/controllers/cost_basis_form_controller.js new file mode 100644 index 000000000..467cd5a9a --- /dev/null +++ b/app/javascript/controllers/cost_basis_form_controller.js @@ -0,0 +1,30 @@ +import { Controller } from "@hotwired/stimulus" + +// Handles bidirectional conversion between total cost basis and per-share cost +// in the manual cost basis entry form. +export default class extends Controller { + static targets = ["total", "perShare", "perShareValue"] + static values = { qty: Number } + + // Called when user types in the total cost basis field + // Updates the per-share display and input to show the calculated value + updatePerShare() { + const total = Number.parseFloat(this.totalTarget.value) || 0 + const qty = this.qtyValue || 1 + const perShare = qty > 0 ? (total / qty).toFixed(2) : "0.00" + this.perShareValueTarget.textContent = perShare + if (this.hasPerShareTarget) { + this.perShareTarget.value = perShare + } + } + + // Called when user types in the per-share field + // Updates the total cost basis field with the calculated value + updateTotal() { + const perShare = Number.parseFloat(this.perShareTarget.value) || 0 + const qty = this.qtyValue || 1 + const total = (perShare * qty).toFixed(2) + this.totalTarget.value = total + this.perShareValueTarget.textContent = perShare.toFixed(2) + } +} diff --git a/app/javascript/controllers/drawer_cost_basis_controller.js b/app/javascript/controllers/drawer_cost_basis_controller.js new file mode 100644 index 000000000..6091bc9cd --- /dev/null +++ b/app/javascript/controllers/drawer_cost_basis_controller.js @@ -0,0 +1,33 @@ +import { Controller } from "@hotwired/stimulus" + +// Handles the inline cost basis editor in the holding drawer. +// Shows/hides the form and handles bidirectional total <-> per-share conversion. +export default class extends Controller { + static targets = ["form", "total", "perShare", "perShareValue"] + static values = { qty: Number } + + toggle(event) { + event.preventDefault() + this.formTarget.classList.toggle("hidden") + } + + // Called when user types in total cost basis field + updatePerShare() { + const total = Number.parseFloat(this.totalTarget.value) || 0 + const qty = this.qtyValue || 1 + const perShare = qty > 0 ? (total / qty).toFixed(2) : "0.00" + this.perShareValueTarget.textContent = perShare + if (this.hasPerShareTarget) { + this.perShareTarget.value = perShare + } + } + + // Called when user types in per-share field + updateTotal() { + const perShare = Number.parseFloat(this.perShareTarget.value) || 0 + const qty = this.qtyValue || 1 + const total = (perShare * qty).toFixed(2) + this.totalTarget.value = total + this.perShareValueTarget.textContent = perShare.toFixed(2) + } +} diff --git a/app/jobs/simplefin_connection_update_job.rb b/app/jobs/simplefin_connection_update_job.rb index 850eaac8b..db4593762 100644 --- a/app/jobs/simplefin_connection_update_job.rb +++ b/app/jobs/simplefin_connection_update_job.rb @@ -2,166 +2,30 @@ class SimplefinConnectionUpdateJob < ApplicationJob queue_as :high_priority # Disable automatic retries for this job since the setup token is single-use. - # If the token claim succeeds but import fails, retrying would fail at claim. + # If the token claim succeeds but sync fails, retrying would fail at claim. discard_on Provider::Simplefin::SimplefinError do |job, error| Rails.logger.error( "SimplefinConnectionUpdateJob discarded: #{error.class} - #{error.message} " \ - "(family_id=#{job.arguments.first[:family_id]}, old_item_id=#{job.arguments.first[:old_simplefin_item_id]})" + "(family_id=#{job.arguments.first[:family_id]}, item_id=#{job.arguments.first[:old_simplefin_item_id]})" ) end def perform(family_id:, old_simplefin_item_id:, setup_token:) family = Family.find(family_id) - old_item = family.simplefin_items.find(old_simplefin_item_id) + simplefin_item = family.simplefin_items.find(old_simplefin_item_id) - # Step 1: Claim the token and create the new item. - # This is the critical step - if it fails, we can safely retry. - # If it succeeds, the token is consumed and we must not retry the claim. - updated_item = family.create_simplefin_item!( - setup_token: setup_token, - item_name: old_item.name + # Step 1: Claim the new token and update the existing item's access_url. + # This preserves all existing account linkages - no need to transfer anything. + simplefin_item.update_access_url!(setup_token: setup_token) + + # Step 2: Sync the item to import fresh data. + # The existing repair_stale_linkages logic handles cases where SimpleFIN + # account IDs changed (e.g., user re-added institution in SimpleFIN Bridge). + simplefin_item.sync_later + + Rails.logger.info( + "SimplefinConnectionUpdateJob: Successfully updated SimplefinItem #{simplefin_item.id} " \ + "with new access_url for family #{family_id}" ) - - # Step 2: Import accounts from SimpleFin. - # If this fails, we have an orphaned item but the token is already consumed. - # We handle this gracefully by marking the item and continuing. - begin - updated_item.import_latest_simplefin_data - rescue => e - Rails.logger.error( - "SimplefinConnectionUpdateJob: import failed for new item #{updated_item.id}: " \ - "#{e.class} - #{e.message}. Item created but may need manual sync." - ) - # Mark the item as needing attention but don't fail the job entirely. - # The item exists and can be synced manually later. - updated_item.update!(status: :requires_update) - # Still proceed to transfer accounts and schedule old item deletion - end - - # Step 3: Transfer account links from old to new item. - # This is idempotent and safe to retry. - # Check for linked accounts via BOTH legacy FK and AccountProvider. - ActiveRecord::Base.transaction do - old_item.simplefin_accounts.includes(:account, account_provider: :account).each do |old_account| - # Get the linked account via either system - linked_account = old_account.current_account - next unless linked_account.present? - - new_simplefin_account = find_matching_simplefin_account(old_account, updated_item.simplefin_accounts) - next unless new_simplefin_account - - # Update legacy FK - linked_account.update!(simplefin_account_id: new_simplefin_account.id) - - # Also migrate AccountProvider if it exists - if old_account.account_provider.present? - old_account.account_provider.update!( - provider_type: "SimplefinAccount", - provider_id: new_simplefin_account.id - ) - else - # Create AccountProvider for consistency - new_simplefin_account.ensure_account_provider! - end - end - end - - # Schedule deletion outside transaction to avoid race condition where - # the job is enqueued even if the transaction rolls back - old_item.destroy_later - - # Only mark as good if import succeeded (status wasn't set to requires_update above) - updated_item.update!(status: :good) unless updated_item.requires_update? end - - private - # Find a matching SimpleFin account in the new item's accounts. - # Uses a multi-tier matching strategy: - # 1. Exact account_id match (preferred) - # 2. Fingerprint match (name + institution + account_type) - # 3. Fuzzy name match with same institution (fallback) - def find_matching_simplefin_account(old_account, new_accounts) - exact_match = new_accounts.find_by(account_id: old_account.account_id) - return exact_match if exact_match - - old_fingerprint = account_fingerprint(old_account) - fingerprint_match = new_accounts.find { |new_account| account_fingerprint(new_account) == old_fingerprint } - return fingerprint_match if fingerprint_match - - old_institution = extract_institution_id(old_account) - old_name_normalized = normalize_account_name(old_account.name) - - new_accounts.find do |new_account| - new_institution = extract_institution_id(new_account) - new_name_normalized = normalize_account_name(new_account.name) - - next false unless old_institution.present? && old_institution == new_institution - - names_similar?(old_name_normalized, new_name_normalized) - end - end - - def account_fingerprint(simplefin_account) - institution_id = extract_institution_id(simplefin_account) - name_normalized = normalize_account_name(simplefin_account.name) - account_type = simplefin_account.account_type.to_s.downcase - - "#{institution_id}:#{name_normalized}:#{account_type}" - end - - def extract_institution_id(simplefin_account) - org_data = simplefin_account.org_data - return nil unless org_data.is_a?(Hash) - - org_data["id"] || org_data["domain"] || org_data["name"]&.downcase&.gsub(/\s+/, "_") - end - - def normalize_account_name(name) - return "" if name.blank? - - name.to_s - .downcase - .gsub(/[^a-z0-9]/, "") - end - - def names_similar?(name1, name2) - return false if name1.blank? || name2.blank? - - return true if name1 == name2 - return true if name1.include?(name2) || name2.include?(name1) - - longer = [ name1.length, name2.length ].max - return false if longer == 0 - - # Use Levenshtein distance for more accurate similarity - distance = levenshtein_distance(name1, name2) - similarity = 1.0 - (distance.to_f / longer) - similarity >= 0.8 - end - - # Compute Levenshtein edit distance between two strings - def levenshtein_distance(s1, s2) - m, n = s1.length, s2.length - return n if m.zero? - return m if n.zero? - - # Use a single array and update in place for memory efficiency - prev_row = (0..n).to_a - curr_row = [] - - (1..m).each do |i| - curr_row[0] = i - (1..n).each do |j| - cost = s1[i - 1] == s2[j - 1] ? 0 : 1 - curr_row[j] = [ - prev_row[j] + 1, # deletion - curr_row[j - 1] + 1, # insertion - prev_row[j - 1] + cost # substitution - ].min - end - prev_row, curr_row = curr_row, prev_row - end - - prev_row[n] - end end diff --git a/app/middleware/omniauth_error_handler.rb b/app/middleware/omniauth_error_handler.rb new file mode 100644 index 000000000..8373cb760 --- /dev/null +++ b/app/middleware/omniauth_error_handler.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +# Middleware to catch OmniAuth/OIDC errors and redirect gracefully +# instead of showing ugly error pages +class OmniauthErrorHandler + def initialize(app) + @app = app + end + + def call(env) + @app.call(env) + rescue OpenIDConnect::Discovery::DiscoveryFailed => e + Rails.logger.error("[OmniAuth] OIDC Discovery failed: #{e.message}") + redirect_to_failure(env, "sso_provider_unavailable") + rescue OmniAuth::Error => e + Rails.logger.error("[OmniAuth] Authentication error: #{e.message}") + redirect_to_failure(env, "sso_failed") + end + + private + + def redirect_to_failure(env, message) + [ + 302, + { "Location" => "/auth/failure?message=#{message}", "Content-Type" => "text/html" }, + [ "Redirecting..." ] + ] + end +end diff --git a/app/models/account/provider_import_adapter.rb b/app/models/account/provider_import_adapter.rb index 52964b165..dac0da8d4 100644 --- a/app/models/account/provider_import_adapter.rb +++ b/app/models/account/provider_import_adapter.rb @@ -16,9 +16,11 @@ class Account::ProviderImportAdapter # @param category_id [Integer, nil] Optional category ID # @param merchant [Merchant, nil] Optional merchant object # @param notes [String, nil] Optional transaction notes/memo + # @param pending_transaction_id [String, nil] Plaid's linking ID for pending→posted reconciliation # @param extra [Hash, nil] Optional provider-specific metadata to merge into transaction.extra + # @param investment_activity_label [String, nil] Optional activity type label (e.g., "Buy", "Dividend") # @return [Entry] The created or updated entry - def import_transaction(external_id:, amount:, currency:, date:, name:, source:, category_id: nil, merchant: nil, notes: nil, extra: nil) + def import_transaction(external_id:, amount:, currency:, date:, name:, source:, category_id: nil, merchant: nil, notes: nil, pending_transaction_id: nil, extra: nil, investment_activity_label: nil) raise ArgumentError, "external_id is required" if external_id.blank? raise ArgumentError, "source is required" if source.blank? @@ -43,6 +45,42 @@ class Account::ProviderImportAdapter end end + # If still a new entry and this is a POSTED transaction, check for matching pending transactions + incoming_pending = extra.is_a?(Hash) && ( + ActiveModel::Type::Boolean.new.cast(extra.dig("simplefin", "pending")) || + ActiveModel::Type::Boolean.new.cast(extra.dig("plaid", "pending")) + ) + + if entry.new_record? && !incoming_pending + pending_match = nil + + # PRIORITY 1: Use Plaid's pending_transaction_id if provided (most reliable) + # Plaid explicitly links pending→posted with this ID - no guessing required + if pending_transaction_id.present? + pending_match = account.entries.find_by(external_id: pending_transaction_id, source: source) + if pending_match + Rails.logger.info("Reconciling pending→posted via Plaid pending_transaction_id: claiming entry #{pending_match.id} (#{pending_match.name}) with new external_id #{external_id}") + end + end + + # PRIORITY 2: Fallback to EXACT amount match (for SimpleFIN and providers without linking IDs) + # Only searches backward in time - pending date must be <= posted date + if pending_match.nil? + pending_match = find_pending_transaction(date: date, amount: amount, currency: currency, source: source) + if pending_match + Rails.logger.info("Reconciling pending→posted via exact amount match: claiming entry #{pending_match.id} (#{pending_match.name}) with new external_id #{external_id}") + end + end + + if pending_match + entry = pending_match + entry.assign_attributes(external_id: external_id) + end + end + + # Track if this is a new posted transaction (for fuzzy suggestion after save) + is_new_posted = entry.new_record? && !incoming_pending + # Validate entryable type matches to prevent external_id collisions if entry.persisted? && !entry.entryable.is_a?(Transaction) raise ArgumentError, "Entry with external_id '#{external_id}' already exists with different entryable type: #{entry.entryable_type}" @@ -77,7 +115,70 @@ class Account::ProviderImportAdapter entry.transaction.extra = existing.deep_merge(incoming) entry.transaction.save! end + + # Set investment activity label if provided and not already set + if investment_activity_label.present? && entry.entryable.is_a?(Transaction) + if entry.transaction.investment_activity_label.blank? + entry.transaction.assign_attributes(investment_activity_label: investment_activity_label) + end + end + entry.save! + entry.transaction.save! if entry.transaction.changed? + + # AFTER save: For NEW posted transactions, check for fuzzy matches to SUGGEST (not auto-claim) + # This handles tip adjustments where auto-matching is too risky + if is_new_posted + # PRIORITY 1: Try medium-confidence fuzzy match (≤30% amount difference) + fuzzy_suggestion = find_pending_transaction_fuzzy( + date: date, + amount: amount, + currency: currency, + source: source, + merchant_id: merchant&.id, + name: name + ) + if fuzzy_suggestion + # Store suggestion on the PENDING entry for user to review + begin + store_duplicate_suggestion( + pending_entry: fuzzy_suggestion, + posted_entry: entry, + reason: "fuzzy_amount_match", + posted_amount: amount, + confidence: "medium" + ) + Rails.logger.info("Suggested potential duplicate (medium confidence): pending entry #{fuzzy_suggestion.id} (#{fuzzy_suggestion.name}, #{fuzzy_suggestion.amount}) may match posted #{entry.name} (#{amount})") + rescue ActiveRecord::RecordInvalid => e + Rails.logger.warn("Failed to store duplicate suggestion for entry #{fuzzy_suggestion.id}: #{e.message}") + end + else + # PRIORITY 2: Try low-confidence match (>30% to 100% difference - big tips) + low_confidence_suggestion = find_pending_transaction_low_confidence( + date: date, + amount: amount, + currency: currency, + source: source, + merchant_id: merchant&.id, + name: name + ) + if low_confidence_suggestion + begin + store_duplicate_suggestion( + pending_entry: low_confidence_suggestion, + posted_entry: entry, + reason: "low_confidence_match", + posted_amount: amount, + confidence: "low" + ) + Rails.logger.info("Suggested potential duplicate (low confidence): pending entry #{low_confidence_suggestion.id} (#{low_confidence_suggestion.name}, #{low_confidence_suggestion.amount}) may match posted #{entry.name} (#{amount})") + rescue ActiveRecord::RecordInvalid => e + Rails.logger.warn("Failed to store duplicate suggestion for entry #{low_confidence_suggestion.id}: #{e.message}") + end + end + end + end + entry end end @@ -222,17 +323,32 @@ class Account::ProviderImportAdapter end end - holding.assign_attributes( + # Reconcile cost_basis to respect priority hierarchy + reconciled = Holding::CostBasisReconciler.reconcile( + existing_holding: holding.persisted? ? holding : nil, + incoming_cost_basis: cost_basis, + incoming_source: "provider" + ) + + # Build base attributes + attributes = { security: security, date: date, currency: currency, qty: quantity, price: price, amount: amount, - cost_basis: cost_basis, account_provider_id: account_provider_id, external_id: external_id - ) + } + + # Only update cost_basis if reconciliation says to + if reconciled[:should_update] + attributes[:cost_basis] = reconciled[:cost_basis] + attributes[:cost_basis_source] = reconciled[:cost_basis_source] + end + + holding.assign_attributes(attributes) begin Holding.transaction(requires_new: true) do @@ -262,11 +378,22 @@ class Account::ProviderImportAdapter updates = { qty: quantity, price: price, - amount: amount, - cost_basis: cost_basis + amount: amount } - # Adopt the row to this provider if it’s currently unowned + # Reconcile cost_basis to respect priority hierarchy + collision_reconciled = Holding::CostBasisReconciler.reconcile( + existing_holding: existing, + incoming_cost_basis: cost_basis, + incoming_source: "provider" + ) + + if collision_reconciled[:should_update] + updates[:cost_basis] = collision_reconciled[:cost_basis] + updates[:cost_basis_source] = collision_reconciled[:cost_basis_source] + end + + # Adopt the row to this provider if it's currently unowned if account_provider_id.present? && existing.account_provider_id.nil? updates[:account_provider_id] = account_provider_id end @@ -444,4 +571,196 @@ class Account::ProviderImportAdapter query.order(created_at: :asc).first end + + # Finds a pending transaction that likely matches a newly posted transaction + # Used to reconcile pending→posted when SimpleFIN gives different IDs for the same transaction + # + # @param date [Date, String] Posted transaction date + # @param amount [BigDecimal, Numeric] Transaction amount (must match exactly) + # @param currency [String] Currency code + # @param source [String] Provider name (e.g., "simplefin") + # @param date_window [Integer] Days to search around the posted date (default: 8) + # @return [Entry, nil] The pending entry or nil if not found + def find_pending_transaction(date:, amount:, currency:, source:, date_window: 8) + date = Date.parse(date.to_s) unless date.is_a?(Date) + + # Look for entries that: + # 1. Same account (implicit via account.entries) + # 2. Same source (simplefin) + # 3. Same amount (exact match - this is the strongest signal) + # 4. Same currency + # 5. Date within window (pending can post days later) + # 6. Is a Transaction (not Trade or Valuation) + # 7. Has pending=true in transaction.extra["simplefin"]["pending"] or extra["plaid"]["pending"] + candidates = account.entries + .joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'") + .where(source: source) + .where(amount: amount) + .where(currency: currency) + .where(date: (date - date_window.days)..date) # Pending must be ON or BEFORE posted date + .where(<<~SQL.squish) + (transactions.extra -> 'simplefin' ->> 'pending')::boolean = true + OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true + SQL + .order(date: :desc) # Prefer most recent pending transaction + + candidates.first + end + + # Finds a pending transaction using fuzzy amount matching for tip adjustments + # Used when exact amount matching fails - handles restaurant tips, adjusted authorizations, etc. + # + # IMPORTANT: Only returns a match if there's exactly ONE candidate to avoid false positives + # with recurring merchant transactions (e.g., gas stations, coffee shops). + # + # @param date [Date, String] Posted transaction date + # @param amount [BigDecimal, Numeric] Posted transaction amount (typically higher due to tip) + # @param currency [String] Currency code + # @param source [String] Provider name (e.g., "simplefin") + # @param merchant_id [Integer, nil] Merchant ID for more accurate matching + # @param name [String, nil] Transaction name for fuzzy name matching + # @param date_window [Integer] Days to search backward from posted date (default: 3 for fuzzy) + # @param amount_tolerance [Float] Maximum percentage difference allowed (default: 0.30 = 30%) + # @return [Entry, nil] The pending entry or nil if not found/ambiguous + def find_pending_transaction_fuzzy(date:, amount:, currency:, source:, merchant_id: nil, name: nil, date_window: 3, amount_tolerance: 0.30) + date = Date.parse(date.to_s) unless date.is_a?(Date) + amount = BigDecimal(amount.to_s) + + # Calculate amount bounds using ABS to handle both positive and negative amounts + # Posted amount should be >= pending (tips add, not subtract) + # Allow posted to be up to 30% higher than pending (covers typical tips) + abs_amount = amount.abs + min_pending_abs = abs_amount / (1 + amount_tolerance) # If posted is 100, pending could be as low as ~77 + max_pending_abs = abs_amount # Pending should not be higher than posted + + # Build base query for pending transactions + # CRITICAL: Pending must be ON or BEFORE the posted date (authorization happens first) + # Use tighter date window (3 days) - tips post quickly, not a week later + # Use ABS() for amount comparison to handle negative amounts correctly + candidates = account.entries + .joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'") + .where(source: source) + .where(currency: currency) + .where(date: (date - date_window.days)..date) # Pending ON or BEFORE posted + .where("ABS(entries.amount) BETWEEN ? AND ?", min_pending_abs, max_pending_abs) + .where(<<~SQL.squish) + (transactions.extra -> 'simplefin' ->> 'pending')::boolean = true + OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true + SQL + + # If merchant_id is provided, prioritize matching by merchant + if merchant_id.present? + merchant_matches = candidates.where("transactions.merchant_id = ?", merchant_id).to_a + # Only match if exactly ONE candidate to avoid false positives + return merchant_matches.first if merchant_matches.size == 1 + if merchant_matches.size > 1 + Rails.logger.info("Skipping fuzzy pending match: #{merchant_matches.size} ambiguous merchant candidates for amount=#{amount} date=#{date}") + end + end + + # If name is provided, try fuzzy name matching as fallback + if name.present? + # Extract first few significant words for comparison + name_words = name.downcase.gsub(/[^a-z0-9\s]/, "").split.first(3).join(" ") + if name_words.present? + name_matches = candidates.select do |c| + c_name_words = c.name.downcase.gsub(/[^a-z0-9\s]/, "").split.first(3).join(" ") + name_words == c_name_words + end + # Only match if exactly ONE candidate to avoid false positives + return name_matches.first if name_matches.size == 1 + if name_matches.size > 1 + Rails.logger.info("Skipping fuzzy pending match: #{name_matches.size} ambiguous name candidates for '#{name_words}' amount=#{amount} date=#{date}") + end + end + end + + # No merchant or name match, return nil (too risky to match on amount alone) + # This prevents false positives when multiple pending transactions exist + nil + end + + # Finds a pending transaction with low confidence (>30% to 100% amount difference) + # Used for large tip scenarios where normal fuzzy matching would miss + # Creates a "review recommended" suggestion rather than "possible duplicate" + # + # @param date [Date, String] Posted transaction date + # @param amount [BigDecimal, Numeric] Posted transaction amount + # @param currency [String] Currency code + # @param source [String] Provider name + # @param merchant_id [Integer, nil] Merchant ID for matching + # @param name [String, nil] Transaction name for matching + # @param date_window [Integer] Days to search backward (default: 3) + # @return [Entry, nil] The pending entry or nil if not found/ambiguous + def find_pending_transaction_low_confidence(date:, amount:, currency:, source:, merchant_id: nil, name: nil, date_window: 3) + date = Date.parse(date.to_s) unless date.is_a?(Date) + amount = BigDecimal(amount.to_s) + + # Allow up to 100% difference (e.g., $50 pending → $100 posted with huge tip) + # This is low confidence - requires strong name/merchant match + # Use ABS to handle both positive and negative amounts correctly + abs_amount = amount.abs + min_pending_abs = abs_amount / 2.0 # Posted could be up to 2x pending + max_pending_abs = abs_amount * 0.77 # Pending must be at least 30% less (to not overlap with fuzzy) + + # Build base query for pending transactions + # Use ABS() for amount comparison to handle negative amounts correctly + candidates = account.entries + .joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'") + .where(source: source) + .where(currency: currency) + .where(date: (date - date_window.days)..date) + .where("ABS(entries.amount) BETWEEN ? AND ?", min_pending_abs, max_pending_abs) + .where(<<~SQL.squish) + (transactions.extra -> 'simplefin' ->> 'pending')::boolean = true + OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true + SQL + + # For low confidence, require BOTH merchant AND name match (stronger signal needed) + if merchant_id.present? && name.present? + name_words = name.downcase.gsub(/[^a-z0-9\s]/, "").split.first(3).join(" ") + return nil if name_words.blank? + + merchant_matches = candidates.where("transactions.merchant_id = ?", merchant_id).to_a + name_matches = merchant_matches.select do |c| + c_name_words = c.name.downcase.gsub(/[^a-z0-9\s]/, "").split.first(3).join(" ") + name_words == c_name_words + end + + # Only match if exactly ONE candidate + return name_matches.first if name_matches.size == 1 + end + + nil + end + + # Stores a duplicate suggestion on a pending entry for user review + # The suggestion is stored in the pending transaction's extra field + # + # @param pending_entry [Entry] The pending entry that may be a duplicate + # @param posted_entry [Entry] The posted entry it may match + # @param reason [String] Why this was flagged (e.g., "fuzzy_amount_match", "low_confidence_match") + # @param posted_amount [BigDecimal] The posted transaction amount + # @param confidence [String] Confidence level: "medium" (≤30% diff) or "low" (>30% diff) + def store_duplicate_suggestion(pending_entry:, posted_entry:, reason:, posted_amount:, confidence: "medium") + return unless pending_entry&.entryable.is_a?(Transaction) + + pending_transaction = pending_entry.entryable + existing_extra = pending_transaction.extra || {} + + # Don't overwrite if already has a suggestion (keep first one found) + return if existing_extra["potential_posted_match"].present? + + pending_transaction.update!( + extra: existing_extra.merge( + "potential_posted_match" => { + "entry_id" => posted_entry.id, + "reason" => reason, + "posted_amount" => posted_amount.to_s, + "confidence" => confidence, + "detected_at" => Date.current.to_s + } + ) + ) + end end diff --git a/app/models/assistant/function/get_holdings.rb b/app/models/assistant/function/get_holdings.rb index 515888e8a..4cc277072 100644 --- a/app/models/assistant/function/get_holdings.rb +++ b/app/models/assistant/function/get_holdings.rb @@ -105,8 +105,8 @@ class Assistant::Function::GetHoldings < Assistant::Function amount: holding.amount.to_f, formatted_amount: holding.amount_money.format, weight: holding.weight&.round(2), - average_cost: holding.avg_cost.to_f, - formatted_average_cost: holding.avg_cost.format, + average_cost: holding.avg_cost&.to_f, + formatted_average_cost: holding.avg_cost&.format, account: holding.account.name, date: holding.date } diff --git a/app/models/coinstats_item/syncer.rb b/app/models/coinstats_item/syncer.rb index 459fa0926..76f7343bb 100644 --- a/app/models/coinstats_item/syncer.rb +++ b/app/models/coinstats_item/syncer.rb @@ -12,11 +12,11 @@ class CoinstatsItem::Syncer # @param sync [Sync] Sync record for status tracking def perform_sync(sync) # Phase 1: Import data from CoinStats API - sync.update!(status_text: "Importing wallets from CoinStats...") if sync.respond_to?(:status_text) + sync.update!(status_text: I18n.t("models.coinstats_item.syncer.importing_wallets")) if sync.respond_to?(:status_text) coinstats_item.import_latest_coinstats_data # Phase 2: Check account setup status and collect sync statistics - sync.update!(status_text: "Checking wallet configuration...") if sync.respond_to?(:status_text) + sync.update!(status_text: I18n.t("models.coinstats_item.syncer.checking_configuration")) if sync.respond_to?(:status_text) total_accounts = coinstats_item.coinstats_accounts.count linked_accounts = coinstats_item.coinstats_accounts.joins(:account_provider).joins(:account).merge(Account.visible) @@ -30,18 +30,18 @@ class CoinstatsItem::Syncer if unlinked_accounts.any? coinstats_item.update!(pending_account_setup: true) - sync.update!(status_text: "#{unlinked_accounts.count} wallets need setup...") if sync.respond_to?(:status_text) + sync.update!(status_text: I18n.t("models.coinstats_item.syncer.wallets_need_setup", count: unlinked_accounts.count)) if sync.respond_to?(:status_text) else coinstats_item.update!(pending_account_setup: false) end # Phase 3: Process holdings for linked accounts only if linked_accounts.any? - sync.update!(status_text: "Processing holdings...") if sync.respond_to?(:status_text) + sync.update!(status_text: I18n.t("models.coinstats_item.syncer.processing_holdings")) if sync.respond_to?(:status_text) coinstats_item.process_accounts # Phase 4: Schedule balance calculations for linked accounts - sync.update!(status_text: "Calculating balances...") if sync.respond_to?(:status_text) + sync.update!(status_text: I18n.t("models.coinstats_item.syncer.calculating_balances")) if sync.respond_to?(:status_text) coinstats_item.schedule_account_syncs( parent_sync: sync, window_start_date: sync.window_start_date, diff --git a/app/models/entry.rb b/app/models/entry.rb index 3a6672b7d..533e389e2 100644 --- a/app/models/entry.rb +++ b/app/models/entry.rb @@ -35,6 +35,188 @@ class Entry < ApplicationRecord ) } + # Pending transaction scopes - check Transaction.extra for provider pending flags + # Works with any provider that stores pending status in extra["provider_name"]["pending"] + scope :pending, -> { + joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'") + .where(<<~SQL.squish) + (transactions.extra -> 'simplefin' ->> 'pending')::boolean = true + OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true + SQL + } + + scope :excluding_pending, -> { + # For non-Transaction entries (Trade, Valuation), always include + # For Transaction entries, exclude if pending flag is true + where(<<~SQL.squish) + entries.entryable_type != 'Transaction' + OR NOT EXISTS ( + SELECT 1 FROM transactions t + WHERE t.id = entries.entryable_id + AND ( + (t.extra -> 'simplefin' ->> 'pending')::boolean = true + OR (t.extra -> 'plaid' ->> 'pending')::boolean = true + ) + ) + SQL + } + + # Find stale pending transactions (pending for more than X days with no matching posted version) + scope :stale_pending, ->(days: 8) { + pending.where("entries.date < ?", days.days.ago.to_date) + } + + # Auto-exclude stale pending transactions for an account + # Called during sync to clean up pending transactions that never posted + # @param account [Account] The account to clean up + # @param days [Integer] Number of days after which pending is considered stale (default: 8) + # @return [Integer] Number of entries excluded + def self.auto_exclude_stale_pending(account:, days: 8) + stale_entries = account.entries.stale_pending(days: days).where(excluded: false) + count = stale_entries.count + + if count > 0 + stale_entries.update_all(excluded: true, updated_at: Time.current) + Rails.logger.info("Auto-excluded #{count} stale pending transaction(s) for account #{account.id} (#{account.name})") + end + + count + end + + # Retroactively reconcile pending transactions that have a matching posted version + # This handles duplicates created before reconciliation code was deployed + # + # @param account [Account, nil] Specific account to clean up, or nil for all accounts + # @param dry_run [Boolean] If true, only report what would be done without making changes + # @param date_window [Integer] Days to search forward for posted matches (default: 8) + # @param amount_tolerance [Float] Percentage difference allowed for fuzzy matching (default: 0.25) + # @return [Hash] Stats about what was reconciled + def self.reconcile_pending_duplicates(account: nil, dry_run: false, date_window: 8, amount_tolerance: 0.25) + stats = { checked: 0, reconciled: 0, details: [] } + + # Get pending entries to check + scope = Entry.pending.where(excluded: false) + scope = scope.where(account: account) if account + + scope.includes(:account, :entryable).find_each do |pending_entry| + stats[:checked] += 1 + acct = pending_entry.account + + # PRIORITY 1: Look for posted transaction with EXACT amount match + # CRITICAL: Only search forward in time - posted date must be >= pending date + exact_candidates = acct.entries + .joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'") + .where.not(id: pending_entry.id) + .where(currency: pending_entry.currency) + .where(amount: pending_entry.amount) + .where(date: pending_entry.date..(pending_entry.date + date_window.days)) # Posted must be ON or AFTER pending date + .where(<<~SQL.squish) + (transactions.extra -> 'simplefin' ->> 'pending')::boolean IS NOT TRUE + AND (transactions.extra -> 'plaid' ->> 'pending')::boolean IS NOT TRUE + SQL + .limit(2) # Only need to know if 0, 1, or 2+ candidates + .to_a # Load limited records to avoid COUNT(*) on .size + + # Handle exact match - auto-exclude only if exactly ONE candidate (high confidence) + # Multiple candidates = ambiguous = skip to avoid excluding wrong entry + if exact_candidates.size == 1 + posted_match = exact_candidates.first + detail = { + pending_id: pending_entry.id, + pending_name: pending_entry.name, + pending_amount: pending_entry.amount.to_f, + pending_date: pending_entry.date, + posted_id: posted_match.id, + posted_name: posted_match.name, + posted_amount: posted_match.amount.to_f, + posted_date: posted_match.date, + account: acct.name, + match_type: "exact" + } + stats[:details] << detail + stats[:reconciled] += 1 + + unless dry_run + pending_entry.update!(excluded: true) + Rails.logger.info("Reconciled pending→posted duplicate: excluded entry #{pending_entry.id} (#{pending_entry.name}) matched to #{posted_match.id}") + end + next + end + + # PRIORITY 2: If no exact match, try fuzzy amount match for tip adjustments + # Store as SUGGESTION instead of auto-excluding (medium confidence) + pending_amount = pending_entry.amount.abs + min_amount = pending_amount + max_amount = pending_amount * (1 + amount_tolerance) + + fuzzy_date_window = 3 + candidates = acct.entries + .joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'") + .where.not(id: pending_entry.id) + .where(currency: pending_entry.currency) + .where(date: pending_entry.date..(pending_entry.date + fuzzy_date_window.days)) # Posted ON or AFTER pending + .where("ABS(entries.amount) BETWEEN ? AND ?", min_amount, max_amount) + .where(<<~SQL.squish) + (transactions.extra -> 'simplefin' ->> 'pending')::boolean IS NOT TRUE + AND (transactions.extra -> 'plaid' ->> 'pending')::boolean IS NOT TRUE + SQL + + # Match by name similarity (first 3 words) + name_words = pending_entry.name.downcase.gsub(/[^a-z0-9\s]/, "").split.first(3).join(" ") + if name_words.present? + matching_candidates = candidates.select do |c| + c_words = c.name.downcase.gsub(/[^a-z0-9\s]/, "").split.first(3).join(" ") + name_words == c_words + end + + # Only suggest if there's exactly ONE matching candidate + # Multiple matches = ambiguous (e.g., recurring gas station visits) = skip + if matching_candidates.size == 1 + fuzzy_match = matching_candidates.first + + detail = { + pending_id: pending_entry.id, + pending_name: pending_entry.name, + pending_amount: pending_entry.amount.to_f, + pending_date: pending_entry.date, + posted_id: fuzzy_match.id, + posted_name: fuzzy_match.name, + posted_amount: fuzzy_match.amount.to_f, + posted_date: fuzzy_match.date, + account: acct.name, + match_type: "fuzzy_suggestion" + } + stats[:details] << detail + + unless dry_run + # Store suggestion on the pending entry instead of auto-excluding + pending_transaction = pending_entry.entryable + if pending_transaction.is_a?(Transaction) + existing_extra = pending_transaction.extra || {} + unless existing_extra["potential_posted_match"].present? + pending_transaction.update!( + extra: existing_extra.merge( + "potential_posted_match" => { + "entry_id" => fuzzy_match.id, + "reason" => "fuzzy_amount_match", + "posted_amount" => fuzzy_match.amount.to_s, + "detected_at" => Date.current.to_s + } + ) + ) + Rails.logger.info("Stored duplicate suggestion for entry #{pending_entry.id} (#{pending_entry.name}) → #{fuzzy_match.id}") + end + end + end + elsif matching_candidates.size > 1 + Rails.logger.info("Skipping fuzzy reconciliation for #{pending_entry.id} (#{pending_entry.name}): #{matching_candidates.size} ambiguous candidates") + end + end + end + + stats + end + def classification amount.negative? ? "income" : "expense" end diff --git a/app/models/entry_search.rb b/app/models/entry_search.rb index 07793df47..b082cac34 100644 --- a/app/models/entry_search.rb +++ b/app/models/entry_search.rb @@ -6,6 +6,7 @@ class EntrySearch attribute :amount, :string attribute :amount_operator, :string attribute :types, :string + attribute :status, array: true attribute :accounts, array: true attribute :account_ids, array: true attribute :start_date, :string @@ -16,7 +17,7 @@ class EntrySearch return scope if search.blank? query = scope - query = query.where("entries.name ILIKE :search", + query = query.where("entries.name ILIKE :search OR entries.notes ILIKE :search", search: "%#{ActiveRecord::Base.sanitize_sql_like(search)}%" ) query @@ -56,6 +57,44 @@ class EntrySearch query = query.where(accounts: { id: account_ids }) if account_ids.present? query end + + def apply_status_filter(scope, statuses) + return scope unless statuses.present? + return scope if statuses.uniq.sort == %w[confirmed pending] # Both selected = no filter + + pending_condition = <<~SQL.squish + entries.entryable_type = 'Transaction' + AND EXISTS ( + SELECT 1 FROM transactions t + WHERE t.id = entries.entryable_id + AND ( + (t.extra -> 'simplefin' ->> 'pending')::boolean = true + OR (t.extra -> 'plaid' ->> 'pending')::boolean = true + ) + ) + SQL + + confirmed_condition = <<~SQL.squish + entries.entryable_type != 'Transaction' + OR NOT EXISTS ( + SELECT 1 FROM transactions t + WHERE t.id = entries.entryable_id + AND ( + (t.extra -> 'simplefin' ->> 'pending')::boolean = true + OR (t.extra -> 'plaid' ->> 'pending')::boolean = true + ) + ) + SQL + + case statuses.sort + when [ "pending" ] + scope.where(pending_condition) + when [ "confirmed" ] + scope.where(confirmed_condition) + else + scope + end + end end def build_query(scope) @@ -64,6 +103,7 @@ class EntrySearch query = self.class.apply_date_filters(query, start_date, end_date) query = self.class.apply_amount_filter(query, amount, amount_operator) query = self.class.apply_accounts_filter(query, accounts, account_ids) + query = self.class.apply_status_filter(query, status) query end end diff --git a/app/models/holding.rb b/app/models/holding.rb index 0b7f602af..fd7fe4284 100644 --- a/app/models/holding.rb +++ b/app/models/holding.rb @@ -3,6 +3,16 @@ class Holding < ApplicationRecord monetize :amount + # Cost basis source priority (higher = takes precedence) + COST_BASIS_SOURCE_PRIORITY = { + nil => 0, + "provider" => 1, + "calculated" => 2, + "manual" => 3 + }.freeze + + COST_BASIS_SOURCES = %w[manual calculated provider].freeze + belongs_to :account belongs_to :security belongs_to :account_provider, optional: true @@ -10,9 +20,12 @@ class Holding < ApplicationRecord validates :qty, :currency, :date, :price, :amount, presence: true validates :qty, :price, :amount, numericality: { greater_than_or_equal_to: 0 } validates :external_id, uniqueness: { scope: :account_id }, allow_blank: true + validates :cost_basis_source, inclusion: { in: COST_BASIS_SOURCES }, allow_nil: true scope :chronological, -> { order(:date) } scope :for, ->(security) { where(security_id: security).order(:date) } + scope :with_locked_cost_basis, -> { where(cost_basis_locked: true) } + scope :with_unlocked_cost_basis, -> { where(cost_basis_locked: false) } delegate :ticker, to: :security @@ -27,12 +40,16 @@ class Holding < ApplicationRecord account.balance.zero? ? 1 : amount / account.balance * 100 end - # Basic approximation of cost-basis + # Returns average cost per share, or nil if unknown. + # # Uses pre-computed cost_basis if available (set during materialization), - # otherwise falls back to calculating from trades + # 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) - return Money.new(cost_basis, currency) if cost_basis.present? + # Use stored cost_basis if available and positive (eliminates N+1 queries) + # Note: cost_basis of 0 is treated as "unknown" since providers sometimes + # return 0 when they don't have the data + return Money.new(cost_basis, currency) if cost_basis.present? && cost_basis.positive? # Fallback to calculation for holdings without pre-computed cost_basis calculate_avg_cost @@ -72,9 +89,57 @@ class Holding < ApplicationRecord account.sync_later end + # Returns the priority level for the current source (higher = better) + def cost_basis_source_priority + COST_BASIS_SOURCE_PRIORITY[cost_basis_source] || 0 + end + + # Check if this holding's cost_basis can be overwritten by the given source + def cost_basis_replaceable_by?(new_source) + return false if cost_basis_locked? + + new_priority = COST_BASIS_SOURCE_PRIORITY[new_source] || 0 + + # Special case: when user unlocks a manual cost_basis, they're opting into + # recalculation. Allow only "calculated" source to replace it (from trades). + # This is the whole point of the unlock action. + if cost_basis_source == "manual" + return new_source == "calculated" + end + + new_priority > cost_basis_source_priority + end + + # Set cost_basis from user input (locks the value) + def set_manual_cost_basis!(value) + update!( + cost_basis: value, + cost_basis_source: "manual", + cost_basis_locked: true + ) + end + + # Unlock cost_basis to allow provider/calculated updates + def unlock_cost_basis! + update!(cost_basis_locked: false) + end + + # Check if cost_basis is known (has a source and positive value) + def cost_basis_known? + cost_basis.present? && cost_basis.positive? && cost_basis_source.present? + end + + # Human-readable source label for UI display + def cost_basis_source_label + return nil unless cost_basis_source.present? + + I18n.t("holdings.cost_basis_sources.#{cost_basis_source}") + end + private def calculate_trend return nil unless amount_money + return nil unless avg_cost # Can't calculate trend without cost basis start_amount = qty * avg_cost @@ -83,6 +148,8 @@ class Holding < ApplicationRecord previous: start_amount end + # Calculates weighted average cost from buy trades. + # Returns nil if no trades exist (cost basis is unknown). def calculate_avg_cost trades = account.trades .with_entry @@ -101,13 +168,10 @@ class Holding < ApplicationRecord Arel.sql("SUM(trades.qty)") ) - weighted_avg = - if total_qty && total_qty > 0 - total_cost / total_qty - else - price - end + # Return nil when no trades exist - cost basis is genuinely unknown + # Previously this fell back to current market price, which was misleading + return nil unless total_qty && total_qty > 0 - Money.new(weighted_avg || price, currency) + Money.new(total_cost / total_qty, currency) end end diff --git a/app/models/holding/cost_basis_reconciler.rb b/app/models/holding/cost_basis_reconciler.rb new file mode 100644 index 000000000..88bd9b635 --- /dev/null +++ b/app/models/holding/cost_basis_reconciler.rb @@ -0,0 +1,58 @@ +# Determines the appropriate cost_basis value and source when updating a holding. +# +# Used by both Materializer (for trade-derived calculations) and +# ProviderImportAdapter (for provider-supplied values) to ensure consistent +# reconciliation logic across all data sources. +# +# Priority hierarchy: manual > calculated > provider > unknown +# +class Holding::CostBasisReconciler + # Determines the appropriate cost_basis value and source for a holding update + # + # @param existing_holding [Holding, nil] The existing holding record (nil for new) + # @param incoming_cost_basis [BigDecimal, nil] The incoming cost_basis value + # @param incoming_source [String] The source of incoming data ('calculated', 'provider') + # @return [Hash] { cost_basis: value, cost_basis_source: source, should_update: boolean } + def self.reconcile(existing_holding:, incoming_cost_basis:, incoming_source:) + # Treat zero cost_basis from provider as unknown + if incoming_source == "provider" && (incoming_cost_basis.nil? || incoming_cost_basis.zero?) + incoming_cost_basis = nil + end + + # New holding - use whatever we have + if existing_holding.nil? + return { + cost_basis: incoming_cost_basis, + cost_basis_source: incoming_cost_basis.present? ? incoming_source : nil, + should_update: true + } + end + + # Locked - never overwrite + if existing_holding.cost_basis_locked? + return { + cost_basis: existing_holding.cost_basis, + cost_basis_source: existing_holding.cost_basis_source, + should_update: false + } + end + + # Check priority - can the incoming source replace the existing? + if existing_holding.cost_basis_replaceable_by?(incoming_source) + if incoming_cost_basis.present? + return { + cost_basis: incoming_cost_basis, + cost_basis_source: incoming_source, + should_update: true + } + end + end + + # Keep existing (equal or lower priority, or incoming is nil) + { + cost_basis: existing_holding.cost_basis, + cost_basis_source: existing_holding.cost_basis_source, + should_update: false + } + end +end diff --git a/app/models/holding/materializer.rb b/app/models/holding/materializer.rb index fee335fd6..3fb704ca7 100644 --- a/app/models/holding/materializer.rb +++ b/app/models/holding/materializer.rb @@ -27,14 +27,75 @@ class Holding::Materializer end def persist_holdings + return if @holdings.empty? + current_time = Time.now - account.holdings.upsert_all( - @holdings.map { |h| h.attributes - .slice("date", "currency", "qty", "price", "amount", "security_id", "cost_basis") - .merge("account_id" => account.id, "updated_at" => current_time) }, - unique_by: %i[account_id security_id date currency] - ) + # Load existing holdings to check locked status and source priority + existing_holdings_map = load_existing_holdings_map + + # Separate holdings into categories based on cost_basis reconciliation + holdings_to_upsert_with_cost = [] + holdings_to_upsert_without_cost = [] + + @holdings.each do |holding| + key = holding_key(holding) + existing = existing_holdings_map[key] + + reconciled = Holding::CostBasisReconciler.reconcile( + existing_holding: existing, + incoming_cost_basis: holding.cost_basis, + incoming_source: "calculated" + ) + + base_attrs = holding.attributes + .slice("date", "currency", "qty", "price", "amount", "security_id") + .merge("account_id" => account.id, "updated_at" => current_time) + + if existing&.cost_basis_locked? + # For locked holdings, preserve ALL cost_basis fields + holdings_to_upsert_without_cost << base_attrs + elsif reconciled[:should_update] && reconciled[:cost_basis].present? + # Update with new cost_basis and source + holdings_to_upsert_with_cost << base_attrs.merge( + "cost_basis" => reconciled[:cost_basis], + "cost_basis_source" => reconciled[:cost_basis_source] + ) + else + # No cost_basis to set, or existing is better - don't touch cost_basis fields + holdings_to_upsert_without_cost << base_attrs + end + end + + # Upsert with cost_basis updates + if holdings_to_upsert_with_cost.any? + account.holdings.upsert_all( + holdings_to_upsert_with_cost, + unique_by: %i[account_id security_id date currency] + ) + end + + # Upsert without cost_basis (preserves existing) + if holdings_to_upsert_without_cost.any? + account.holdings.upsert_all( + holdings_to_upsert_without_cost, + unique_by: %i[account_id security_id date currency] + ) + end + end + + def load_existing_holdings_map + # Load holdings that might affect reconciliation: + # - Locked holdings (must preserve their cost_basis) + # - Holdings with a source (need to check priority) + account.holdings + .where(cost_basis_locked: true) + .or(account.holdings.where.not(cost_basis_source: nil)) + .index_by { |h| holding_key(h) } + end + + def holding_key(holding) + [ holding.account_id || account.id, holding.security_id, holding.date, holding.currency ] end def purge_stale_holdings diff --git a/app/models/import.rb b/app/models/import.rb index fb8c72686..866e4b05c 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -22,6 +22,7 @@ class Import < ApplicationRecord belongs_to :account, optional: true before_validation :set_default_number_format + before_validation :ensure_utf8_encoding scope :ordered, -> { order(created_at: :desc) } @@ -302,6 +303,68 @@ class Import < ApplicationRecord self.number_format ||= "1,234.56" # Default to US/UK format end + # Common encodings to try when UTF-8 detection fails + # Windows-1250 is prioritized for Central/Eastern European languages + COMMON_ENCODINGS = [ "Windows-1250", "Windows-1252", "ISO-8859-1", "ISO-8859-2" ].freeze + + def ensure_utf8_encoding + # Handle nil or empty string first (before checking if changed) + return if raw_file_str.nil? || raw_file_str.bytesize == 0 + + # Only process if the attribute was changed + # Use will_save_change_to_attribute? which is safer for binary data + return unless will_save_change_to_raw_file_str? + + # If already valid UTF-8, nothing to do + begin + if raw_file_str.encoding == Encoding::UTF_8 && raw_file_str.valid_encoding? + return + end + rescue ArgumentError + # raw_file_str might have invalid encoding, continue to detection + end + + # Detect encoding using rchardet + begin + require "rchardet" + detection = CharDet.detect(raw_file_str) + detected_encoding = detection["encoding"] + confidence = detection["confidence"] + + # Only convert if we have reasonable confidence in the detection + if detected_encoding && confidence > 0.75 + # Force encoding and convert to UTF-8 + self.raw_file_str = raw_file_str.force_encoding(detected_encoding).encode("UTF-8", invalid: :replace, undef: :replace) + else + # Fallback: try common encodings + try_common_encodings + end + rescue LoadError + # rchardet not available, fallback to trying common encodings + try_common_encodings + rescue ArgumentError, Encoding::CompatibilityError => e + # Handle encoding errors by falling back to common encodings + try_common_encodings + end + end + + def try_common_encodings + COMMON_ENCODINGS.each do |encoding| + begin + test = raw_file_str.dup.force_encoding(encoding) + if test.valid_encoding? + self.raw_file_str = test.encode("UTF-8", invalid: :replace, undef: :replace) + return + end + rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError + next + end + end + + # If nothing worked, force UTF-8 and replace invalid bytes + self.raw_file_str = raw_file_str.force_encoding("UTF-8").scrub("?") + end + def account_belongs_to_family return if account.nil? return if account.family_id == family_id diff --git a/app/models/income_statement.rb b/app/models/income_statement.rb index 6c4e16b90..83aa2c9fd 100644 --- a/app/models/income_statement.rb +++ b/app/models/income_statement.rb @@ -12,7 +12,9 @@ class IncomeStatement end def totals(transactions_scope: nil, date_range:) - transactions_scope ||= family.transactions.visible + # Default to excluding pending transactions from budget/analytics calculations + # Pending transactions shouldn't affect budget totals until they post + transactions_scope ||= family.transactions.visible.excluding_pending result = totals_query(transactions_scope: transactions_scope, date_range: date_range) @@ -64,7 +66,8 @@ class IncomeStatement end def build_period_total(classification:, period:) - totals = totals_query(transactions_scope: family.transactions.visible.in_period(period), date_range: period.date_range).select { |t| t.classification == classification } + # Exclude pending transactions from budget calculations + totals = totals_query(transactions_scope: family.transactions.visible.excluding_pending.in_period(period), date_range: period.date_range).select { |t| t.classification == classification } classification_total = totals.sum(&:total) uncategorized_category = family.categories.uncategorized @@ -127,7 +130,7 @@ class IncomeStatement sql_hash = Digest::MD5.hexdigest(transactions_scope.to_sql) Rails.cache.fetch([ - "income_statement", "totals_query", family.id, sql_hash, family.entries_cache_version + "income_statement", "totals_query", "v2", family.id, sql_hash, family.entries_cache_version ]) { Totals.new(family, transactions_scope: transactions_scope, date_range: date_range).call } end diff --git a/app/models/income_statement/category_stats.rb b/app/models/income_statement/category_stats.rb index 3daf69c5a..f4eb2815f 100644 --- a/app/models/income_statement/category_stats.rb +++ b/app/models/income_statement/category_stats.rb @@ -47,8 +47,10 @@ class IncomeStatement::CategoryStats er.to_currency = :target_currency ) WHERE a.family_id = :family_id - AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment') + AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment', 'investment_contribution') AND ae.excluded = false + AND (t.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true + AND (t.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true GROUP BY c.id, period, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END ) SELECT diff --git a/app/models/income_statement/family_stats.rb b/app/models/income_statement/family_stats.rb index a2509b114..48b0d9507 100644 --- a/app/models/income_statement/family_stats.rb +++ b/app/models/income_statement/family_stats.rb @@ -44,8 +44,10 @@ class IncomeStatement::FamilyStats er.to_currency = :target_currency ) WHERE a.family_id = :family_id - AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment') + AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment', 'investment_contribution') AND ae.excluded = false + AND (t.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true + AND (t.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true GROUP BY period, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END ) SELECT diff --git a/app/models/income_statement/totals.rb b/app/models/income_statement/totals.rb index 355212486..758ae6be3 100644 --- a/app/models/income_statement/totals.rb +++ b/app/models/income_statement/totals.rb @@ -69,9 +69,9 @@ class IncomeStatement::Totals er.from_currency = ae.currency AND er.to_currency = :target_currency ) - WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment') + WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment', 'investment_contribution') AND ae.excluded = false - AND a.family_id = :family_id + AND a.family_id = :family_id AND a.status IN ('draft', 'active') GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END; SQL @@ -95,9 +95,9 @@ class IncomeStatement::Totals er.from_currency = ae.currency AND er.to_currency = :target_currency ) - WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment') + WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment', 'investment_contribution') AND ae.excluded = false - AND a.family_id = :family_id + AND a.family_id = :family_id AND a.status IN ('draft', 'active') GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END SQL @@ -126,7 +126,7 @@ class IncomeStatement::Totals WHERE a.family_id = :family_id AND a.status IN ('draft', 'active') AND ae.excluded = false - AND ae.date BETWEEN :start_date AND :end_date + AND ae.date BETWEEN :start_date AND :end_date GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END, CASE WHEN t.category_id IS NULL THEN true ELSE false END SQL end diff --git a/app/models/investment_statement.rb b/app/models/investment_statement.rb index ee8daacfa..11cfdffa4 100644 --- a/app/models/investment_statement.rb +++ b/app/models/investment_statement.rb @@ -133,8 +133,12 @@ class InvestmentStatement holdings = current_holdings.to_a return nil if holdings.empty? - current = holdings.sum(&:amount) - previous = holdings.sum { |h| h.qty * h.avg_cost.amount } + # Only include holdings with known cost basis in the calculation + holdings_with_cost_basis = holdings.select(&:avg_cost) + return nil if holdings_with_cost_basis.empty? + + current = holdings_with_cost_basis.sum(&:amount) + previous = holdings_with_cost_basis.sum { |h| h.qty * h.avg_cost.amount } Trend.new(current: current, previous: previous) end diff --git a/app/models/oidc_identity.rb b/app/models/oidc_identity.rb index 78276546d..e8993142f 100644 --- a/app/models/oidc_identity.rb +++ b/app/models/oidc_identity.rb @@ -10,12 +10,79 @@ class OidcIdentity < ApplicationRecord update!(last_authenticated_at: Time.current) end + # Sync user attributes from IdP on each login + # Updates stored identity info and syncs name to user (not email - that's identity) + def sync_user_attributes!(auth) + # Extract groups from claims (various common claim names) + groups = extract_groups(auth) + + # Update stored identity info with latest from IdP + update!(info: { + email: auth.info&.email, + name: auth.info&.name, + first_name: auth.info&.first_name, + last_name: auth.info&.last_name, + groups: groups + }) + + # Sync name to user if provided (keep existing if IdP doesn't provide) + user.update!( + first_name: auth.info&.first_name.presence || user.first_name, + last_name: auth.info&.last_name.presence || user.last_name + ) + + # Apply role mapping based on group membership + apply_role_mapping!(groups) + end + + # Extract groups from various common IdP claim formats + def extract_groups(auth) + # Try various common group claim locations + groups = auth.extra&.raw_info&.groups || + auth.extra&.raw_info&.[]("groups") || + auth.extra&.raw_info&.[]("Group") || + auth.info&.groups || + auth.extra&.raw_info&.[]("http://schemas.microsoft.com/ws/2008/06/identity/claims/groups") || + auth.extra&.raw_info&.[]("cognito:groups") || + [] + + # Normalize to array of strings + Array(groups).map(&:to_s) + end + + # Apply role mapping based on IdP group membership + def apply_role_mapping!(groups) + config = provider_config + return unless config.present? + + role_mapping = config.dig(:settings, :role_mapping) || config.dig(:settings, "role_mapping") + return unless role_mapping.present? + + # Check roles in order of precedence (highest to lowest) + %w[super_admin admin member].each do |role| + mapped_groups = role_mapping[role] || role_mapping[role.to_sym] || [] + mapped_groups = Array(mapped_groups) + + # Check if user is in any of the mapped groups + if mapped_groups.include?("*") || (mapped_groups & groups).any? + # Only update if different to avoid unnecessary writes + user.update!(role: role) unless user.role == role + Rails.logger.info("[SSO] Applied role mapping: user_id=#{user.id} role=#{role} groups=#{groups}") + return + end + end + end + # Extract and store relevant info from OmniAuth auth hash def self.create_from_omniauth(auth, user) + # Extract issuer from OIDC auth response if available + issuer = auth.extra&.raw_info&.iss || auth.extra&.raw_info&.[]("iss") + create!( user: user, provider: auth.provider, uid: auth.uid, + issuer: issuer, info: { email: auth.info&.email, name: auth.info&.name, @@ -25,4 +92,20 @@ class OidcIdentity < ApplicationRecord last_authenticated_at: Time.current ) end + + # Find the configured provider for this identity + def provider_config + Rails.configuration.x.auth.sso_providers&.find { |p| p[:name] == provider || p[:id] == provider } + end + + # Validate that the stored issuer matches the configured provider's issuer + # Returns true if valid, false if mismatch (security concern) + def issuer_matches_config? + return true if issuer.blank? # Backward compatibility for old records + + config = provider_config + return true if config.blank? || config[:issuer].blank? # No config to validate against + + issuer == config[:issuer] + end end diff --git a/app/models/plaid_account/investments/transactions_processor.rb b/app/models/plaid_account/investments/transactions_processor.rb index 922a3f2a6..d90657ce6 100644 --- a/app/models/plaid_account/investments/transactions_processor.rb +++ b/app/models/plaid_account/investments/transactions_processor.rb @@ -1,6 +1,24 @@ class PlaidAccount::Investments::TransactionsProcessor SecurityNotFoundError = Class.new(StandardError) + # Map Plaid investment transaction types to activity labels + # All values must be valid Transaction::ACTIVITY_LABELS + PLAID_TYPE_TO_LABEL = { + "buy" => "Buy", + "sell" => "Sell", + "cancel" => "Other", + "cash" => "Other", + "fee" => "Fee", + "transfer" => "Transfer", + "dividend" => "Dividend", + "interest" => "Interest", + "contribution" => "Contribution", + "withdrawal" => "Withdrawal", + "dividend reinvestment" => "Reinvestment", + "spin off" => "Other", + "split" => "Other" + }.freeze + def initialize(plaid_account, security_resolver:) @plaid_account = plaid_account @security_resolver = security_resolver @@ -68,10 +86,16 @@ class PlaidAccount::Investments::TransactionsProcessor currency: transaction["iso_currency_code"], date: transaction["date"], name: transaction["name"], - source: "plaid" + source: "plaid", + investment_activity_label: label_from_plaid_type(transaction) ) end + def label_from_plaid_type(transaction) + plaid_type = transaction["type"]&.downcase + PLAID_TYPE_TO_LABEL[plaid_type] || "Other" + end + def transactions plaid_account.raw_investments_payload["transactions"] || [] end diff --git a/app/models/plaid_account/transactions/processor.rb b/app/models/plaid_account/transactions/processor.rb index 90e55d2d9..a59045a37 100644 --- a/app/models/plaid_account/transactions/processor.rb +++ b/app/models/plaid_account/transactions/processor.rb @@ -51,7 +51,20 @@ class PlaidAccount::Transactions::Processor modified = plaid_account.raw_transactions_payload["modified"] || [] added = plaid_account.raw_transactions_payload["added"] || [] - modified + added + transactions = modified + added + + # Filter out pending transactions based on env var or Setting + # Priority: env var > Setting (allows runtime changes via UI) + include_pending = if ENV["PLAID_INCLUDE_PENDING"].present? + Rails.configuration.x.plaid.include_pending + else + Setting.syncs_include_pending + end + unless include_pending + transactions = transactions.reject { |t| t["pending"] == true } + end + + transactions end def removed_transactions diff --git a/app/models/plaid_entry/processor.rb b/app/models/plaid_entry/processor.rb index e3bc8c74c..c0a890038 100644 --- a/app/models/plaid_entry/processor.rb +++ b/app/models/plaid_entry/processor.rb @@ -16,9 +16,11 @@ class PlaidEntry::Processor source: "plaid", category_id: matched_category&.id, merchant: merchant, + pending_transaction_id: pending_transaction_id, # Plaid's linking ID for pending→posted extra: { plaid: { - pending: plaid_transaction["pending"] + pending: plaid_transaction["pending"], + pending_transaction_id: pending_transaction_id # Also store for reference } } ) @@ -55,6 +57,12 @@ class PlaidEntry::Processor plaid_transaction["date"] end + # Plaid provides this linking ID when a posted transaction matches a pending one + # This is the most reliable way to reconcile pending→posted + def pending_transaction_id + plaid_transaction["pending_transaction_id"] + end + def detailed_category plaid_transaction.dig("personal_finance_category", "detailed") end diff --git a/app/models/provider/coinstats.rb b/app/models/provider/coinstats.rb index e9d406ab9..0fc0d8695 100644 --- a/app/models/provider/coinstats.rb +++ b/app/models/provider/coinstats.rb @@ -25,6 +25,9 @@ class Provider::Coinstats < Provider res = self.class.get("#{BASE_URL}/wallet/blockchains", headers: auth_headers) handle_response(res) end + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error "CoinStats API: GET /wallet/blockchains failed: #{e.class}: #{e.message}" + raise Error, "CoinStats API request failed: #{e.message}" end # Returns blockchain options formatted for select dropdowns @@ -75,6 +78,9 @@ class Provider::Coinstats < Provider ) handle_response(res) end + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error "CoinStats API: GET /wallet/balances failed: #{e.class}: #{e.message}" + raise Error, "CoinStats API request failed: #{e.message}" end # Extract balance data for a specific wallet from bulk response @@ -114,6 +120,9 @@ class Provider::Coinstats < Provider ) handle_response(res) end + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error "CoinStats API: GET /wallet/transactions failed: #{e.class}: #{e.message}" + raise Error, "CoinStats API request failed: #{e.message}" end # Extract transaction data for a specific wallet from bulk response @@ -162,23 +171,36 @@ class Provider::Coinstats < Provider when 200 JSON.parse(response.body, symbolize_names: true) when 400 - raise Error, "CoinStats: #{response.code} Bad Request - Invalid parameters or request format #{response.body}" + log_api_error(response, "Bad Request") + raise Error, "CoinStats: Invalid request parameters" when 401 - raise Error, "CoinStats: #{response.code} Unauthorized - Invalid or missing API key #{response.body}" + log_api_error(response, "Unauthorized") + raise Error, "CoinStats: Invalid or missing API key" when 403 - raise Error, "CoinStats: #{response.code} Forbidden - #{response.body}" + log_api_error(response, "Forbidden") + raise Error, "CoinStats: Access denied" when 404 - raise Error, "CoinStats: #{response.code} Not Found - Resource not found #{response.body}" + log_api_error(response, "Not Found") + raise Error, "CoinStats: Resource not found" when 409 - raise Error, "CoinStats: #{response.code} Conflict - Resource conflict #{response.body}" + log_api_error(response, "Conflict") + raise Error, "CoinStats: Resource conflict" when 429 - raise Error, "CoinStats: #{response.code} Too Many Requests - Rate limit exceeded #{response.body}" + log_api_error(response, "Too Many Requests") + raise Error, "CoinStats: Rate limit exceeded, try again later" when 500 - raise Error, "CoinStats: #{response.code} Internal Server Error - Server error #{response.body}" + log_api_error(response, "Internal Server Error") + raise Error, "CoinStats: Server error, try again later" when 503 - raise Error, "CoinStats: #{response.code} Service Unavailable - #{response.body}" + log_api_error(response, "Service Unavailable") + raise Error, "CoinStats: Service temporarily unavailable" else - raise Error, "CoinStats: #{response.code} Unexpected Error - #{response.body}" + log_api_error(response, "Unexpected Error") + raise Error, "CoinStats: An unexpected error occurred" end end + + def log_api_error(response, error_type) + Rails.logger.error "CoinStats API: #{response.code} #{error_type} - #{response.body}" + end end diff --git a/app/models/provider/openai/auto_categorizer.rb b/app/models/provider/openai/auto_categorizer.rb index 1d369e4f3..36cdf80bf 100644 --- a/app/models/provider/openai/auto_categorizer.rb +++ b/app/models/provider/openai/auto_categorizer.rb @@ -534,7 +534,8 @@ class Provider::Openai::AutoCategorizer # Format transactions in a simpler, more readable way for smaller LLMs def format_transactions_simply transactions.map do |t| - "- ID: #{t[:id]}, Amount: #{t[:amount]}, Type: #{t[:classification]}, Description: \"#{t[:description]}\"" + description = t[:description].presence || t[:merchant].presence || "" + "- ID: #{t[:id]}, Amount: #{t[:amount]}, Type: #{t[:classification]}, Description: \"#{description}\"" end.join("\n") end end diff --git a/app/models/provider/openai/auto_merchant_detector.rb b/app/models/provider/openai/auto_merchant_detector.rb index 3de15709d..a64dc17fa 100644 --- a/app/models/provider/openai/auto_merchant_detector.rb +++ b/app/models/provider/openai/auto_merchant_detector.rb @@ -491,7 +491,9 @@ class Provider::Openai::AutoMerchantDetector # Format transactions in a simpler, more readable way for smaller LLMs def format_transactions_simply transactions.map do |t| - "- ID: #{t[:id]}, Description: \"#{t[:name] || t[:description]}\"" + parts = [ t[:merchant], t[:description] ].compact.reject(&:blank?) + combined = parts.join(" - ") + "- ID: #{t[:id]}, Description: \"#{combined}\"" end.join("\n") end end diff --git a/app/models/rule/action_executor/set_investment_activity_label.rb b/app/models/rule/action_executor/set_investment_activity_label.rb new file mode 100644 index 000000000..21e421292 --- /dev/null +++ b/app/models/rule/action_executor/set_investment_activity_label.rb @@ -0,0 +1,31 @@ +class Rule::ActionExecutor::SetInvestmentActivityLabel < Rule::ActionExecutor + def label + "Set investment activity label" + end + + def type + "select" + end + + def options + Transaction::ACTIVITY_LABELS.map { |l| [ l, l ] } + end + + def execute(transaction_scope, value: nil, ignore_attribute_locks: false, rule_run: nil) + return 0 unless Transaction::ACTIVITY_LABELS.include?(value) + + scope = transaction_scope + + unless ignore_attribute_locks + scope = scope.enrichable(:investment_activity_label) + end + + count_modified_resources(scope) do |txn| + txn.enrich_attribute( + :investment_activity_label, + value, + source: "rule" + ) + end + end +end diff --git a/app/models/rule/registry/transaction_resource.rb b/app/models/rule/registry/transaction_resource.rb index d051b5837..fac1d6667 100644 --- a/app/models/rule/registry/transaction_resource.rb +++ b/app/models/rule/registry/transaction_resource.rb @@ -20,6 +20,7 @@ class Rule::Registry::TransactionResource < Rule::Registry Rule::ActionExecutor::SetTransactionTags.new(rule), Rule::ActionExecutor::SetTransactionMerchant.new(rule), Rule::ActionExecutor::SetTransactionName.new(rule), + Rule::ActionExecutor::SetInvestmentActivityLabel.new(rule), Rule::ActionExecutor::ExcludeTransaction.new(rule) ] diff --git a/app/models/setting.rb b/app/models/setting.rb index 1b5706171..4407d7293 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -16,6 +16,30 @@ class Setting < RailsSettings::Base field :exchange_rate_provider, type: :string, default: ENV.fetch("EXCHANGE_RATE_PROVIDER", "twelve_data") field :securities_provider, type: :string, default: ENV.fetch("SECURITIES_PROVIDER", "twelve_data") + # Sync settings - check both provider env vars for default + # Only defaults to true if neither provider explicitly disables pending + SYNCS_INCLUDE_PENDING_DEFAULT = begin + simplefin = ENV.fetch("SIMPLEFIN_INCLUDE_PENDING", "1") == "1" + plaid = ENV.fetch("PLAID_INCLUDE_PENDING", "1") == "1" + simplefin && plaid + end + field :syncs_include_pending, type: :boolean, default: SYNCS_INCLUDE_PENDING_DEFAULT + field :auto_sync_enabled, type: :boolean, default: ENV.fetch("AUTO_SYNC_ENABLED", "1") == "1" + field :auto_sync_time, type: :string, default: ENV.fetch("AUTO_SYNC_TIME", "02:22") + field :auto_sync_timezone, type: :string, default: ENV.fetch("AUTO_SYNC_TIMEZONE", "UTC") + + AUTO_SYNC_TIME_FORMAT = /\A([01]?\d|2[0-3]):([0-5]\d)\z/ + + def self.valid_auto_sync_time?(time_str) + return false if time_str.blank? + AUTO_SYNC_TIME_FORMAT.match?(time_str.to_s.strip) + end + + def self.valid_auto_sync_timezone?(timezone_str) + return false if timezone_str.blank? + ActiveSupport::TimeZone[timezone_str].present? + end + # Dynamic fields are now stored as individual entries with "dynamic:" prefix # This prevents race conditions and ensures each field is independently managed diff --git a/app/models/simplefin_account/processor.rb b/app/models/simplefin_account/processor.rb index 569f515cd..37d211673 100644 --- a/app/models/simplefin_account/processor.rb +++ b/app/models/simplefin_account/processor.rb @@ -46,7 +46,9 @@ class SimplefinAccount::Processor avail = to_decimal(simplefin_account.available_balance) # Choose an observed value prioritizing posted balance first - observed = bal.nonzero? ? bal : avail + # Use available_balance only when current_balance is truly missing (nil), + # not when it's explicitly zero (e.g., dormant credit card with no debt) + observed = simplefin_account.current_balance.nil? ? avail : bal # Determine if this should be treated as a liability for normalization is_linked_liability = [ "CreditCard", "Loan" ].include?(account.accountable_type) diff --git a/app/models/simplefin_entry/processor.rb b/app/models/simplefin_entry/processor.rb index 4ef68449e..db4b5689b 100644 --- a/app/models/simplefin_entry/processor.rb +++ b/app/models/simplefin_entry/processor.rb @@ -34,9 +34,27 @@ class SimplefinEntry::Processor # Include provider-supplied extra hash if present sf["extra"] = data[:extra] if data[:extra].is_a?(Hash) - # Pending detection: only use explicit provider flag + # Pending detection: explicit flag OR inferred from posted=0 + transacted_at + # SimpleFIN indicates pending via: + # 1. pending: true (explicit flag) + # 2. posted=0 (epoch zero) + transacted_at present (implicit - some banks use this pattern) + # + # Note: We only infer from posted=0, NOT from posted=nil/blank, because some providers + # don't supply posted dates even for settled transactions (would cause false positives). # We always set the key (true or false) to ensure deep_merge overwrites any stale value - if ActiveModel::Type::Boolean.new.cast(data[:pending]) + is_pending = if ActiveModel::Type::Boolean.new.cast(data[:pending]) + true + else + # Infer pending ONLY when posted is explicitly 0 (epoch) AND transacted_at is present + # posted=nil/blank is NOT treated as pending (some providers omit posted for settled txns) + posted_val = data[:posted] + transacted_val = data[:transacted_at] + posted_is_epoch_zero = posted_val.present? && posted_val.to_i.zero? + transacted_present = transacted_val.present? && transacted_val.to_i > 0 + posted_is_epoch_zero && transacted_present + end + + if is_pending sf["pending"] = true Rails.logger.debug("SimpleFIN: flagged pending transaction #{external_id}") else diff --git a/app/models/simplefin_item.rb b/app/models/simplefin_item.rb index 45acd07b7..daabf99b3 100644 --- a/app/models/simplefin_item.rb +++ b/app/models/simplefin_item.rb @@ -55,6 +55,20 @@ class SimplefinItem < ApplicationRecord SimplefinItem::Importer.new(self, simplefin_provider: simplefin_provider, sync: sync).import end + # Update the access_url by claiming a new setup token. + # This is used when reconnecting an existing SimpleFIN connection. + # Unlike create_simplefin_item!, this updates in-place, preserving all account linkages. + def update_access_url!(setup_token:) + new_access_url = simplefin_provider.claim_access_url(setup_token) + + update!( + access_url: new_access_url, + status: :good + ) + + self + end + def process_accounts # Process accounts linked via BOTH legacy FK and AccountProvider # Use direct query to ensure fresh data from DB, bypassing any association cache @@ -271,8 +285,8 @@ class SimplefinItem < ApplicationRecord return nil unless latest # If sync has statistics, use them - if latest.sync_stats.present? - stats = latest.sync_stats + stats = parse_sync_stats(latest.sync_stats) + if stats.present? total = stats["total_accounts"] || 0 linked = stats["linked_accounts"] || 0 unlinked = stats["unlinked_accounts"] || 0 @@ -399,7 +413,68 @@ class SimplefinItem < ApplicationRecord issues end + # Get reconciled duplicates count from the last sync + # Returns { count: N, message: "..." } or { count: 0 } if none + def last_sync_reconciled_status + latest_sync = syncs.ordered.first + return { count: 0 } unless latest_sync + + stats = parse_sync_stats(latest_sync.sync_stats) + count = stats&.dig("pending_reconciled").to_i + if count > 0 + { + count: count, + message: I18n.t("simplefin_items.reconciled_status.message", count: count) + } + else + { count: 0 } + end + end + + # Count stale pending transactions (>8 days old) across all linked accounts + # Returns { count: N, accounts: [names] } or { count: 0 } if none + def stale_pending_status(days: 8) + # Get all accounts linked to this SimpleFIN item + # Eager-load both association paths to avoid N+1 on current_account method + linked_accounts = simplefin_accounts.includes(:account, :linked_account).filter_map(&:current_account) + return { count: 0 } if linked_accounts.empty? + + # Batch query to avoid N+1 + account_ids = linked_accounts.map(&:id) + counts_by_account = Entry.stale_pending(days: days) + .where(excluded: false) + .where(account_id: account_ids) + .group(:account_id) + .count + + account_counts = linked_accounts + .map { |account| { account: account, count: counts_by_account[account.id].to_i } } + .select { |ac| ac[:count] > 0 } + + total = account_counts.sum { |ac| ac[:count] } + if total > 0 + { + count: total, + accounts: account_counts.map { |ac| ac[:account].name }, + message: I18n.t("simplefin_items.stale_pending_status.message", count: total, days: days) + } + else + { count: 0 } + end + end + private + # Parse sync_stats, handling cases where it might be a raw JSON string + # (e.g., from console testing or bypassed serialization) + def parse_sync_stats(sync_stats) + return nil if sync_stats.blank? + return sync_stats if sync_stats.is_a?(Hash) + + if sync_stats.is_a?(String) + JSON.parse(sync_stats) rescue nil + end + end + def remove_simplefin_item # SimpleFin doesn't require server-side cleanup like Plaid # The access URL just becomes inactive diff --git a/app/models/simplefin_item/importer.rb b/app/models/simplefin_item/importer.rb index f4d3d9e3a..c465bf951 100644 --- a/app/models/simplefin_item/importer.rb +++ b/app/models/simplefin_item/importer.rb @@ -9,6 +9,7 @@ class SimplefinItem::Importer @simplefin_provider = simplefin_provider @sync = sync @enqueued_holdings_job_ids = Set.new + @reconciled_account_ids = Set.new # Debounce pending reconciliation per run end def import @@ -44,6 +45,11 @@ class SimplefinItem::Importer Rails.logger.info "SimplefinItem::Importer - Using REGULAR SYNC (last_synced_at=#{simplefin_item.last_synced_at&.strftime('%Y-%m-%d %H:%M')})" import_regular_sync end + + # Reset status to good if no auth errors occurred in this sync. + # This allows the item to recover automatically when a bank's auth issue is resolved + # in SimpleFIN Bridge, without requiring the user to manually reconnect. + maybe_clear_requires_update_status rescue RateLimitedError => e stats["rate_limited"] = true stats["rate_limited_at"] = Time.current.iso8601 @@ -201,8 +207,8 @@ class SimplefinItem::Importer end adapter.update_balance( - balance: normalized, - cash_balance: cash, + balance: account_data[:balance], + cash_balance: account_data[:"available-balance"], source: "simplefin" ) end @@ -320,6 +326,21 @@ class SimplefinItem::Importer sync.update_columns(sync_stats: merged) # avoid callbacks/validations during tight loops end + # Reset status to good if no auth errors occurred in this sync. + # This allows automatic recovery when a bank's auth issue is resolved in SimpleFIN Bridge. + def maybe_clear_requires_update_status + return unless simplefin_item.requires_update? + + auth_errors = stats.dig("error_buckets", "auth").to_i + if auth_errors.zero? + simplefin_item.update!(status: :good) + Rails.logger.info( + "SimpleFIN: cleared requires_update status for item ##{simplefin_item.id} " \ + "(no auth errors in this sync)" + ) + end + end + def import_with_chunked_history # SimpleFin's actual limit is 60 days (not 365 as documented) # Use 60-day chunks to stay within limits @@ -428,9 +449,12 @@ class SimplefinItem::Importer perform_account_discovery # Step 2: Fetch transactions/holdings using the regular window. + # Note: Don't pass explicit `pending:` here - let fetch_accounts_data use the + # SIMPLEFIN_INCLUDE_PENDING config. This allows users to disable pending transactions + # if their bank's SimpleFIN integration produces duplicates when pending→posted. start_date = determine_sync_start_date Rails.logger.info "SimplefinItem::Importer - import_regular_sync: last_synced_at=#{simplefin_item.last_synced_at&.strftime('%Y-%m-%d %H:%M')} => start_date=#{start_date&.strftime('%Y-%m-%d')}" - accounts_data = fetch_accounts_data(start_date: start_date, pending: true) + accounts_data = fetch_accounts_data(start_date: start_date) return if accounts_data.nil? # Error already handled # Store raw payload @@ -554,9 +578,15 @@ class SimplefinItem::Importer # Returns a Hash payload with keys like :accounts, or nil when an error is # handled internally via `handle_errors`. def fetch_accounts_data(start_date:, end_date: nil, pending: nil) - # Determine whether to include pending based on explicit arg or global config. - # `Rails.configuration.x.simplefin.include_pending` is ENV-backed. - effective_pending = pending.nil? ? Rails.configuration.x.simplefin.include_pending : pending + # Determine whether to include pending based on explicit arg, env var, or Setting. + # Priority: explicit arg > env var > Setting (allows runtime changes via UI) + effective_pending = if !pending.nil? + pending + elsif ENV["SIMPLEFIN_INCLUDE_PENDING"].present? + Rails.configuration.x.simplefin.include_pending + else + Setting.syncs_include_pending + end # Debug logging to track exactly what's being sent to SimpleFin API start_str = start_date.respond_to?(:strftime) ? start_date.strftime("%Y-%m-%d") : "none" @@ -806,6 +836,15 @@ class SimplefinItem::Importer # Post-save side effects acct = simplefin_account.current_account if acct + # Handle pending transaction reconciliation (debounced per run to avoid + # repeated scans during chunked history imports) + unless @reconciled_account_ids.include?(acct.id) + @reconciled_account_ids << acct.id + reconcile_and_track_pending_duplicates(acct) + exclude_and_track_stale_pending(acct) + track_stale_unmatched_pending(acct) + end + # Refresh credit attributes when available-balance present if acct.accountable_type == "CreditCard" && account_data[:"available-balance"].present? begin @@ -1146,19 +1185,103 @@ class SimplefinItem::Importer ids.group_by(&:itself).select { |_, v| v.size > 1 }.keys end - # --- Simple helpers for numeric handling in normalization --- - def to_decimal(value) - return BigDecimal("0") if value.nil? - case value - when BigDecimal then value - when String then BigDecimal(value) rescue BigDecimal("0") - when Numeric then BigDecimal(value.to_s) - else - BigDecimal("0") + # Reconcile pending transactions that have a matching posted version + # Handles duplicates where pending and posted both exist (tip adjustments, etc.) + def reconcile_and_track_pending_duplicates(account) + reconcile_stats = Entry.reconcile_pending_duplicates(account: account, dry_run: false) + + exact_matches = reconcile_stats[:details].select { |d| d[:match_type] == "exact" } + fuzzy_suggestions = reconcile_stats[:details].select { |d| d[:match_type] == "fuzzy_suggestion" } + + if exact_matches.any? + stats["pending_reconciled"] = stats.fetch("pending_reconciled", 0) + exact_matches.size + stats["pending_reconciled_details"] ||= [] + exact_matches.each do |detail| + stats["pending_reconciled_details"] << { + "account_name" => detail[:account], + "pending_name" => detail[:pending_name], + "posted_name" => detail[:posted_name] + } + end + stats["pending_reconciled_details"] = stats["pending_reconciled_details"].last(50) end + + if fuzzy_suggestions.any? + stats["duplicate_suggestions_created"] = stats.fetch("duplicate_suggestions_created", 0) + fuzzy_suggestions.size + stats["duplicate_suggestions_details"] ||= [] + fuzzy_suggestions.each do |detail| + stats["duplicate_suggestions_details"] << { + "account_name" => detail[:account], + "pending_name" => detail[:pending_name], + "posted_name" => detail[:posted_name] + } + end + stats["duplicate_suggestions_details"] = stats["duplicate_suggestions_details"].last(50) + end + rescue => e + Rails.logger.warn("SimpleFin: pending reconciliation failed for account #{account.id}: #{e.class} - #{e.message}") + record_reconciliation_error("pending_reconciliation", account, e) end - def same_sign?(a, b) - (a.positive? && b.positive?) || (a.negative? && b.negative?) + # Auto-exclude stale pending transactions (>8 days old with no matching posted version) + # Prevents orphaned pending transactions from affecting budgets indefinitely + def exclude_and_track_stale_pending(account) + excluded_count = Entry.auto_exclude_stale_pending(account: account) + return unless excluded_count > 0 + + stats["stale_pending_excluded"] = stats.fetch("stale_pending_excluded", 0) + excluded_count + stats["stale_pending_details"] ||= [] + stats["stale_pending_details"] << { + "account_name" => account.name, + "account_id" => account.id, + "count" => excluded_count + } + stats["stale_pending_details"] = stats["stale_pending_details"].last(50) + rescue => e + Rails.logger.warn("SimpleFin: stale pending cleanup failed for account #{account.id}: #{e.class} - #{e.message}") + record_reconciliation_error("stale_pending_cleanup", account, e) + end + + # Track stale pending transactions that couldn't be matched (for user awareness) + # These are >8 days old, still pending, and have no duplicate suggestion + def track_stale_unmatched_pending(account) + stale_unmatched = account.entries + .joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'") + .where(excluded: false) + .where("entries.date < ?", 8.days.ago.to_date) + .where(<<~SQL.squish) + (transactions.extra -> 'simplefin' ->> 'pending')::boolean = true + OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true + SQL + .where(<<~SQL.squish) + transactions.extra -> 'potential_posted_match' IS NULL + SQL + .count + + return unless stale_unmatched > 0 + + stats["stale_unmatched_pending"] = stats.fetch("stale_unmatched_pending", 0) + stale_unmatched + stats["stale_unmatched_details"] ||= [] + stats["stale_unmatched_details"] << { + "account_name" => account.name, + "account_id" => account.id, + "count" => stale_unmatched + } + stats["stale_unmatched_details"] = stats["stale_unmatched_details"].last(50) + rescue => e + Rails.logger.warn("SimpleFin: stale unmatched tracking failed for account #{account.id}: #{e.class} - #{e.message}") + record_reconciliation_error("stale_unmatched_tracking", account, e) + end + + # Record reconciliation errors to sync_stats for UI visibility + def record_reconciliation_error(context, account, error) + stats["reconciliation_errors"] ||= [] + stats["reconciliation_errors"] << { + "context" => context, + "account_id" => account.id, + "account_name" => account.name, + "error" => "#{error.class}: #{error.message}" + } + stats["reconciliation_errors"] = stats["reconciliation_errors"].last(20) end end diff --git a/app/models/sso_audit_log.rb b/app/models/sso_audit_log.rb new file mode 100644 index 000000000..21aa4e05e --- /dev/null +++ b/app/models/sso_audit_log.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +class SsoAuditLog < ApplicationRecord + belongs_to :user, optional: true + + # Event types for SSO audit logging + EVENT_TYPES = %w[ + login + login_failed + logout + logout_idp + link + unlink + jit_account_created + ].freeze + + validates :event_type, presence: true, inclusion: { in: EVENT_TYPES } + + scope :recent, -> { order(created_at: :desc) } + scope :for_user, ->(user) { where(user: user) } + scope :by_event, ->(event) { where(event_type: event) } + + class << self + # Log a successful SSO login + def log_login!(user:, provider:, request:, metadata: {}) + create!( + user: user, + event_type: "login", + provider: provider, + ip_address: request.remote_ip, + user_agent: request.user_agent&.truncate(500), + metadata: metadata + ) + end + + # Log a failed SSO login attempt + def log_login_failed!(provider:, request:, reason:, metadata: {}) + create!( + user: nil, + event_type: "login_failed", + provider: provider, + ip_address: request.remote_ip, + user_agent: request.user_agent&.truncate(500), + metadata: metadata.merge(reason: reason) + ) + end + + # Log a logout (local only) + def log_logout!(user:, request:, metadata: {}) + create!( + user: user, + event_type: "logout", + provider: nil, + ip_address: request.remote_ip, + user_agent: request.user_agent&.truncate(500), + metadata: metadata + ) + end + + # Log a federated logout (to IdP) + def log_logout_idp!(user:, provider:, request:, metadata: {}) + create!( + user: user, + event_type: "logout_idp", + provider: provider, + ip_address: request.remote_ip, + user_agent: request.user_agent&.truncate(500), + metadata: metadata + ) + end + + # Log an account link (existing user links SSO identity) + def log_link!(user:, provider:, request:, metadata: {}) + create!( + user: user, + event_type: "link", + provider: provider, + ip_address: request.remote_ip, + user_agent: request.user_agent&.truncate(500), + metadata: metadata + ) + end + + # Log an account unlink (user disconnects SSO identity) + def log_unlink!(user:, provider:, request:, metadata: {}) + create!( + user: user, + event_type: "unlink", + provider: provider, + ip_address: request.remote_ip, + user_agent: request.user_agent&.truncate(500), + metadata: metadata + ) + end + + # Log JIT account creation via SSO + def log_jit_account_created!(user:, provider:, request:, metadata: {}) + create!( + user: user, + event_type: "jit_account_created", + provider: provider, + ip_address: request.remote_ip, + user_agent: request.user_agent&.truncate(500), + metadata: metadata + ) + end + end +end diff --git a/app/models/sso_provider.rb b/app/models/sso_provider.rb new file mode 100644 index 000000000..21e3dbf33 --- /dev/null +++ b/app/models/sso_provider.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +class SsoProvider < ApplicationRecord + # Encrypt sensitive credentials using Rails 7.2 built-in encryption + encrypts :client_secret, deterministic: false + + # Default enabled to true for new providers + attribute :enabled, :boolean, default: true + + # Validations + validates :strategy, presence: true, inclusion: { + in: %w[openid_connect google_oauth2 github saml], + message: "%{value} is not a supported strategy" + } + validates :name, presence: true, uniqueness: true, format: { + with: /\A[a-z0-9_]+\z/, + message: "must contain only lowercase letters, numbers, and underscores" + } + validates :label, presence: true + validates :enabled, inclusion: { in: [ true, false ] } + + # Strategy-specific validations + validate :validate_oidc_fields, if: -> { strategy == "openid_connect" } + validate :validate_oauth_fields, if: -> { strategy.in?(%w[google_oauth2 github]) } + validate :validate_saml_fields, if: -> { strategy == "saml" } + validate :validate_default_role_setting + # Note: OIDC discovery validation is done client-side via Stimulus + # Server-side validation can fail due to network issues, so we skip it + # validate :validate_oidc_discovery, if: -> { strategy == "openid_connect" && issuer.present? && will_save_change_to_issuer? } + + # Scopes + scope :enabled, -> { where(enabled: true) } + scope :by_strategy, ->(strategy) { where(strategy: strategy) } + + # Convert to hash format compatible with OmniAuth initializer + def to_omniauth_config + { + id: name, + strategy: strategy, + name: name, + label: label, + icon: icon, + issuer: issuer, + client_id: client_id, + client_secret: client_secret, + redirect_uri: redirect_uri, + settings: settings || {} + }.compact + end + + private + def validate_oidc_fields + if issuer.blank? + errors.add(:issuer, "is required for OpenID Connect providers") + elsif issuer.present? && !valid_url?(issuer) + errors.add(:issuer, "must be a valid URL") + end + + errors.add(:client_id, "is required for OpenID Connect providers") if client_id.blank? + errors.add(:client_secret, "is required for OpenID Connect providers") if client_secret.blank? + + if redirect_uri.present? && !valid_url?(redirect_uri) + errors.add(:redirect_uri, "must be a valid URL") + end + end + + def validate_oauth_fields + errors.add(:client_id, "is required for OAuth providers") if client_id.blank? + errors.add(:client_secret, "is required for OAuth providers") if client_secret.blank? + end + + def validate_saml_fields + # SAML requires either a metadata URL or manual configuration + idp_metadata_url = settings&.dig("idp_metadata_url") + idp_sso_url = settings&.dig("idp_sso_url") + + if idp_metadata_url.blank? && idp_sso_url.blank? + errors.add(:settings, "Either IdP Metadata URL or IdP SSO URL is required for SAML providers") + end + + # If using manual config, require certificate + if idp_metadata_url.blank? && idp_sso_url.present? + idp_cert = settings&.dig("idp_certificate") + idp_fingerprint = settings&.dig("idp_cert_fingerprint") + + if idp_cert.blank? && idp_fingerprint.blank? + errors.add(:settings, "Either IdP Certificate or Certificate Fingerprint is required when not using metadata URL") + end + end + + # Validate URL formats if provided + if idp_metadata_url.present? && !valid_url?(idp_metadata_url) + errors.add(:settings, "IdP Metadata URL must be a valid URL") + end + + if idp_sso_url.present? && !valid_url?(idp_sso_url) + errors.add(:settings, "IdP SSO URL must be a valid URL") + end + end + + def validate_default_role_setting + default_role = settings&.dig("default_role") + return if default_role.blank? + + unless User.roles.key?(default_role) + errors.add(:settings, "default_role must be member, admin, or super_admin") + end + end + + def validate_oidc_discovery + return unless issuer.present? + + begin + discovery_url = issuer.end_with?("/") ? "#{issuer}.well-known/openid-configuration" : "#{issuer}/.well-known/openid-configuration" + response = Faraday.get(discovery_url) do |req| + req.options.timeout = 5 + req.options.open_timeout = 3 + end + + unless response.success? + errors.add(:issuer, "discovery endpoint returned #{response.status}") + return + end + + discovery_data = JSON.parse(response.body) + unless discovery_data["issuer"].present? + errors.add(:issuer, "discovery endpoint did not return valid issuer") + end + rescue Faraday::Error => e + errors.add(:issuer, "could not connect to discovery endpoint: #{e.message}") + rescue JSON::ParserError + errors.add(:issuer, "discovery endpoint returned invalid JSON") + rescue StandardError => e + errors.add(:issuer, "discovery validation failed: #{e.message}") + end + end + + def valid_url?(url) + uri = URI.parse(url) + uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) + rescue URI::InvalidURIError + false + end +end diff --git a/app/models/sso_provider_tester.rb b/app/models/sso_provider_tester.rb new file mode 100644 index 000000000..0464088c4 --- /dev/null +++ b/app/models/sso_provider_tester.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true + +# Tests SSO provider configuration by validating discovery endpoints +class SsoProviderTester + attr_reader :provider, :result + + Result = Struct.new(:success?, :message, :details, keyword_init: true) + + def initialize(provider) + @provider = provider + @result = nil + end + + def test! + @result = case provider.strategy + when "openid_connect" + test_oidc_discovery + when "google_oauth2" + test_google_oauth + when "github" + test_github_oauth + when "saml" + test_saml_metadata + else + Result.new(success?: false, message: "Unknown strategy: #{provider.strategy}", details: {}) + end + end + + private + + def test_oidc_discovery + return Result.new(success?: false, message: "Issuer URL is required", details: {}) if provider.issuer.blank? + + discovery_url = build_discovery_url(provider.issuer) + + begin + response = Faraday.get(discovery_url) do |req| + req.options.timeout = 10 + req.options.open_timeout = 5 + end + + unless response.success? + return Result.new( + success?: false, + message: "Discovery endpoint returned HTTP #{response.status}", + details: { url: discovery_url, status: response.status } + ) + end + + discovery = JSON.parse(response.body) + + # Validate required OIDC fields + required_fields = %w[issuer authorization_endpoint token_endpoint] + missing = required_fields.select { |f| discovery[f].blank? } + + if missing.any? + return Result.new( + success?: false, + message: "Discovery document missing required fields: #{missing.join(", ")}", + details: { url: discovery_url, missing_fields: missing } + ) + end + + # Check if issuer matches + if discovery["issuer"] != provider.issuer && discovery["issuer"] != provider.issuer.chomp("/") + return Result.new( + success?: false, + message: "Issuer mismatch: expected #{provider.issuer}, got #{discovery["issuer"]}", + details: { expected: provider.issuer, actual: discovery["issuer"] } + ) + end + + Result.new( + success?: true, + message: "OIDC discovery validated successfully", + details: { + issuer: discovery["issuer"], + authorization_endpoint: discovery["authorization_endpoint"], + token_endpoint: discovery["token_endpoint"], + end_session_endpoint: discovery["end_session_endpoint"], + scopes_supported: discovery["scopes_supported"] + } + ) + + rescue Faraday::TimeoutError + Result.new(success?: false, message: "Connection timed out", details: { url: discovery_url }) + rescue Faraday::ConnectionFailed => e + Result.new(success?: false, message: "Connection failed: #{e.message}", details: { url: discovery_url }) + rescue JSON::ParserError + Result.new(success?: false, message: "Invalid JSON response from discovery endpoint", details: { url: discovery_url }) + rescue StandardError => e + Result.new(success?: false, message: "Error: #{e.message}", details: { url: discovery_url }) + end + end + + def test_google_oauth + # Google OAuth doesn't require discovery validation - just check credentials present + if provider.client_id.blank? + return Result.new(success?: false, message: "Client ID is required", details: {}) + end + + if provider.client_secret.blank? + return Result.new(success?: false, message: "Client Secret is required", details: {}) + end + + Result.new( + success?: true, + message: "Google OAuth2 configuration looks valid", + details: { + note: "Full validation occurs during actual authentication" + } + ) + end + + def test_github_oauth + # GitHub OAuth doesn't require discovery validation - just check credentials present + if provider.client_id.blank? + return Result.new(success?: false, message: "Client ID is required", details: {}) + end + + if provider.client_secret.blank? + return Result.new(success?: false, message: "Client Secret is required", details: {}) + end + + Result.new( + success?: true, + message: "GitHub OAuth configuration looks valid", + details: { + note: "Full validation occurs during actual authentication" + } + ) + end + + def test_saml_metadata + # SAML testing - check for IdP metadata or SSO URL + if provider.settings&.dig("idp_metadata_url").blank? && + provider.settings&.dig("idp_sso_url").blank? + return Result.new( + success?: false, + message: "Either IdP Metadata URL or IdP SSO URL is required", + details: {} + ) + end + + # If metadata URL is provided, try to fetch it + metadata_url = provider.settings&.dig("idp_metadata_url") + if metadata_url.present? + begin + response = Faraday.get(metadata_url) do |req| + req.options.timeout = 10 + req.options.open_timeout = 5 + end + + unless response.success? + return Result.new( + success?: false, + message: "Metadata endpoint returned HTTP #{response.status}", + details: { url: metadata_url, status: response.status } + ) + end + + # Basic XML validation + unless response.body.include?("<") && response.body.include?("EntityDescriptor") + return Result.new( + success?: false, + message: "Response does not appear to be valid SAML metadata", + details: { url: metadata_url } + ) + end + + return Result.new( + success?: true, + message: "SAML metadata fetched successfully", + details: { url: metadata_url } + ) + rescue Faraday::TimeoutError + return Result.new(success?: false, message: "Connection timed out", details: { url: metadata_url }) + rescue Faraday::ConnectionFailed => e + return Result.new(success?: false, message: "Connection failed: #{e.message}", details: { url: metadata_url }) + rescue StandardError => e + return Result.new(success?: false, message: "Error: #{e.message}", details: { url: metadata_url }) + end + end + + Result.new( + success?: true, + message: "SAML configuration looks valid", + details: { + note: "Full validation occurs during actual authentication" + } + ) + end + + def build_discovery_url(issuer) + if issuer.end_with?("/") + "#{issuer}.well-known/openid-configuration" + else + "#{issuer}/.well-known/openid-configuration" + end + end +end diff --git a/app/models/transaction.rb b/app/models/transaction.rb index dd8eb3064..e1a86afb5 100644 --- a/app/models/transaction.rb +++ b/app/models/transaction.rb @@ -16,7 +16,30 @@ class Transaction < ApplicationRecord funds_movement: "funds_movement", # Movement of funds between accounts, excluded from budget analytics cc_payment: "cc_payment", # A CC payment, excluded from budget analytics (CC payments offset the sum of expense transactions) loan_payment: "loan_payment", # A payment to a Loan account, treated as an expense in budgets - one_time: "one_time" # A one-time expense/income, excluded from budget analytics + one_time: "one_time", # A one-time expense/income, excluded from budget analytics + investment_contribution: "investment_contribution" # Transfer to investment/crypto account, excluded from budget analytics + } + + # All valid investment activity labels (for UI dropdown) + ACTIVITY_LABELS = [ + "Buy", "Sell", "Sweep In", "Sweep Out", "Dividend", "Reinvestment", + "Interest", "Fee", "Transfer", "Contribution", "Withdrawal", "Exchange", "Other" + ].freeze + + # Pending transaction scopes - filter based on provider pending flags in extra JSONB + # Works with any provider that stores pending status in extra["provider_name"]["pending"] + scope :pending, -> { + where(<<~SQL.squish) + (transactions.extra -> 'simplefin' ->> 'pending')::boolean = true + OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true + SQL + } + + scope :excluding_pending, -> { + where(<<~SQL.squish) + (transactions.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true + AND (transactions.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true + SQL } # Overarching grouping method for all transfer-type transactions @@ -42,7 +65,85 @@ class Transaction < ApplicationRecord 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 + private + + 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) diff --git a/app/models/transaction/search.rb b/app/models/transaction/search.rb index 8dc3efcb5..e46a66472 100644 --- a/app/models/transaction/search.rb +++ b/app/models/transaction/search.rb @@ -6,6 +6,7 @@ class Transaction::Search attribute :amount, :string attribute :amount_operator, :string attribute :types, array: true + attribute :status, array: true attribute :accounts, array: true attribute :account_ids, array: true attribute :start_date, :string @@ -30,6 +31,7 @@ class Transaction::Search query = apply_active_accounts_filter(query, active_accounts_only) query = apply_category_filter(query, categories) query = apply_type_filter(query, types) + query = apply_status_filter(query, status) query = apply_merchant_filter(query, merchants) query = apply_tag_filter(query, tags) query = EntrySearch.apply_search_filter(query, search) @@ -47,8 +49,8 @@ class Transaction::Search Rails.cache.fetch("transaction_search_totals/#{cache_key_base}") do result = transactions_scope .select( - "COALESCE(SUM(CASE WHEN entries.amount >= 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment') THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as expense_total", - "COALESCE(SUM(CASE WHEN entries.amount < 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment') THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as income_total", + "COALESCE(SUM(CASE WHEN entries.amount >= 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment', 'investment_contribution') THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as expense_total", + "COALESCE(SUM(CASE WHEN entries.amount < 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment', 'investment_contribution') THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as income_total", "COUNT(entries.id) as transactions_count" ) .joins( @@ -98,14 +100,14 @@ class Transaction::Search if parent_category_ids.empty? query = query.left_joins(:category).where( "categories.name IN (?) OR ( - categories.id IS NULL AND (transactions.kind NOT IN ('funds_movement', 'cc_payment')) + categories.id IS NULL AND (transactions.kind NOT IN ('funds_movement', 'cc_payment', 'investment_contribution')) )", categories ) else query = query.left_joins(:category).where( "categories.name IN (?) OR categories.parent_id IN (?) OR ( - categories.id IS NULL AND (transactions.kind NOT IN ('funds_movement', 'cc_payment')) + categories.id IS NULL AND (transactions.kind NOT IN ('funds_movement', 'cc_payment', 'investment_contribution')) )", categories, parent_category_ids ) @@ -153,4 +155,28 @@ class Transaction::Search return query unless tags.present? query.joins(:tags).where(tags: { name: tags }) end + + def apply_status_filter(query, statuses) + return query unless statuses.present? + return query if statuses.uniq.sort == [ "confirmed", "pending" ] # Both selected = no filter + + pending_condition = <<~SQL.squish + (transactions.extra -> 'simplefin' ->> 'pending')::boolean = true + OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true + SQL + + confirmed_condition = <<~SQL.squish + (transactions.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true + AND (transactions.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true + SQL + + case statuses.sort + when [ "pending" ] + query.where(pending_condition) + when [ "confirmed" ] + query.where(confirmed_condition) + else + query + end + end end diff --git a/app/models/transfer.rb b/app/models/transfer.rb index 93de0a068..d2dcbf667 100644 --- a/app/models/transfer.rb +++ b/app/models/transfer.rb @@ -16,6 +16,10 @@ class Transfer < ApplicationRecord def kind_for_account(account) if account.loan? "loan_payment" + elsif account.credit_card? + "cc_payment" + elsif account.investment? || account.crypto? + "investment_contribution" elsif account.liability? "cc_payment" else diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb new file mode 100644 index 000000000..e232556f6 --- /dev/null +++ b/app/policies/application_policy.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +class ApplicationPolicy + attr_reader :user, :record + + def initialize(user, record) + @user = user + @record = record + end + + def index? + false + end + + def show? + false + end + + def create? + false + end + + def new? + create? + end + + def update? + false + end + + def edit? + update? + end + + def destroy? + false + end + + class Scope + def initialize(user, scope) + @user = user + @scope = scope + end + + def resolve + raise NoMethodError, "You must define #resolve in #{self.class}" + end + + private + + attr_reader :user, :scope + end +end diff --git a/app/policies/sso_provider_policy.rb b/app/policies/sso_provider_policy.rb new file mode 100644 index 000000000..d68010415 --- /dev/null +++ b/app/policies/sso_provider_policy.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +class SsoProviderPolicy < ApplicationPolicy + # Only super admins can manage SSO providers (instance-wide auth config) + def index? + user&.super_admin? + end + + def show? + user&.super_admin? + end + + def create? + user&.super_admin? + end + + def new? + create? + end + + def update? + user&.super_admin? + end + + def edit? + update? + end + + def destroy? + user&.super_admin? + end + + def toggle? + update? + end + + def test_connection? + user&.super_admin? + end + + class Scope < ApplicationPolicy::Scope + def resolve + if user&.super_admin? + scope.all + else + scope.none + end + end + end +end diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb new file mode 100644 index 000000000..c40bf6007 --- /dev/null +++ b/app/policies/user_policy.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class UserPolicy < ApplicationPolicy + # Only super_admins can manage user roles + def index? + user&.super_admin? + end + + def update? + return false unless user&.super_admin? + # Prevent users from changing their own role (must be done by another super_admin) + user.id != record.id + end + + class Scope < ApplicationPolicy::Scope + def resolve + if user&.super_admin? + scope.all + else + scope.none + end + end + end +end diff --git a/app/services/auto_sync_scheduler.rb b/app/services/auto_sync_scheduler.rb new file mode 100644 index 000000000..44adea3b0 --- /dev/null +++ b/app/services/auto_sync_scheduler.rb @@ -0,0 +1,52 @@ +class AutoSyncScheduler + JOB_NAME = "sync_all_accounts" + + def self.sync! + Rails.logger.info("[AutoSyncScheduler] auto_sync_enabled=#{Setting.auto_sync_enabled}, time=#{Setting.auto_sync_time}") + if Setting.auto_sync_enabled? + upsert_job + else + remove_job + end + end + + def self.upsert_job + time_str = Setting.auto_sync_time || "02:22" + timezone_str = Setting.auto_sync_timezone || "UTC" + + unless Setting.valid_auto_sync_time?(time_str) + Rails.logger.error("[AutoSyncScheduler] Invalid time format: #{time_str}, using default 02:22") + time_str = "02:22" + end + + hour, minute = time_str.split(":").map(&:to_i) + timezone = ActiveSupport::TimeZone[timezone_str] || ActiveSupport::TimeZone["UTC"] + local_time = timezone.now.change(hour: hour, min: minute, sec: 0) + utc_time = local_time.utc + + cron = "#{utc_time.min} #{utc_time.hour} * * *" + + job = Sidekiq::Cron::Job.create( + name: JOB_NAME, + cron: cron, + class: "SyncAllJob", + queue: "scheduled", + description: "Syncs all accounts for all families" + ) + + if job.nil? || (job.respond_to?(:valid?) && !job.valid?) + error_msg = job.respond_to?(:errors) ? job.errors.to_a.join(", ") : "unknown error" + Rails.logger.error("[AutoSyncScheduler] Failed to create cron job: #{error_msg}") + raise StandardError, "Failed to create sync schedule: #{error_msg}" + end + + Rails.logger.info("[AutoSyncScheduler] Created cron job with schedule: #{cron} (#{time_str} #{timezone_str})") + job + end + + def self.remove_job + if (job = Sidekiq::Cron::Job.find(JOB_NAME)) + job.destroy + end + end +end diff --git a/app/services/provider_loader.rb b/app/services/provider_loader.rb new file mode 100644 index 000000000..e2bf35365 --- /dev/null +++ b/app/services/provider_loader.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +# Service class to load SSO provider configurations from either YAML or database +# based on the :db_sso_providers feature flag. +# +# Usage: +# providers = ProviderLoader.load_providers +# +class ProviderLoader + CACHE_KEY = "sso_providers_config" + CACHE_EXPIRES_IN = 5.minutes + + class << self + # Load providers from either DB or YAML based on feature flag + # Returns an array of provider configuration hashes + def load_providers + # Check cache first for performance + cached = Rails.cache.read(CACHE_KEY) + return cached if cached.present? + + providers = if use_database_providers? + load_from_database + else + load_from_yaml + end + + # Cache the result + Rails.cache.write(CACHE_KEY, providers, expires_in: CACHE_EXPIRES_IN) + providers + end + + # Clear the provider cache (call after updating providers in admin) + def clear_cache + Rails.cache.delete(CACHE_KEY) + end + + private + def use_database_providers? + return false if Rails.env.test? + + begin + # Check if feature exists, create if not (defaults to disabled) + unless Flipper.exist?(:db_sso_providers) + Flipper.add(:db_sso_providers) + end + Flipper.enabled?(:db_sso_providers) + rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid, StandardError => e + # Database not ready or other error, fall back to YAML + Rails.logger.warn("[ProviderLoader] Could not check feature flag (#{e.class}), falling back to YAML providers") + false + end + end + + def load_from_database + begin + providers = SsoProvider.enabled.order(:name).map(&:to_omniauth_config) + + if providers.empty? + Rails.logger.info("[ProviderLoader] No enabled providers in database, falling back to YAML") + return load_from_yaml + end + + Rails.logger.info("[ProviderLoader] Loaded #{providers.count} provider(s) from database") + providers + rescue ActiveRecord::StatementInvalid, ActiveRecord::NoDatabaseError => e + Rails.logger.error("[ProviderLoader] Database error loading providers: #{e.message}, falling back to YAML") + load_from_yaml + rescue StandardError => e + Rails.logger.error("[ProviderLoader] Unexpected error loading providers from database: #{e.message}, falling back to YAML") + load_from_yaml + end + end + + def load_from_yaml + begin + auth_config = Rails.application.config_for(:auth) + providers = auth_config.dig("providers") || [] + + Rails.logger.info("[ProviderLoader] Loaded #{providers.count} provider(s) from YAML") + providers + rescue RuntimeError, Errno::ENOENT => e + Rails.logger.error("[ProviderLoader] Error loading auth.yml: #{e.message}") + [] + end + end + end +end diff --git a/app/services/simplefin_item/unlinker.rb b/app/services/simplefin_item/unlinker.rb deleted file mode 100644 index d676af999..000000000 --- a/app/services/simplefin_item/unlinker.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -# DEPRECATED: This thin wrapper remains only for backward compatibility. -# Business logic has moved into `SimplefinItem::Unlinking` (model concern). -# Prefer calling `item.unlink_all!(dry_run: ...)` directly. -class SimplefinItem::Unlinker - attr_reader :item, :dry_run - - def initialize(item, dry_run: false) - @item = item - @dry_run = dry_run - end - - def unlink_all! - item.unlink_all!(dry_run: dry_run) - end -end diff --git a/app/views/admin/sso_providers/_form.html.erb b/app/views/admin/sso_providers/_form.html.erb new file mode 100644 index 000000000..590ba740e --- /dev/null +++ b/app/views/admin/sso_providers/_form.html.erb @@ -0,0 +1,280 @@ +<%# locals: (sso_provider:) %> + +<% if sso_provider.errors.any? %> +
+
+ <%= icon "alert-circle", class: "w-5 h-5 text-destructive mr-2 shrink-0" %> +
+

+ <%= pluralize(sso_provider.errors.count, "error") %> prohibited this provider from being saved: +

+
    + <% sso_provider.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+
+
+<% end %> + +<%= styled_form_with model: [:admin, sso_provider], class: "space-y-6", data: { controller: "admin-sso-form" } do |form| %> +
+

Basic Information

+ +
+ <%= form.select :strategy, + options_for_select([ + ["OpenID Connect", "openid_connect"], + ["SAML 2.0", "saml"], + ["Google OAuth2", "google_oauth2"], + ["GitHub", "github"] + ], sso_provider.strategy), + { label: "Strategy" }, + { data: { action: "change->admin-sso-form#toggleFields" } } %> + + <%= form.text_field :name, + label: "Name", + placeholder: "e.g., keycloak, authentik", + required: true, + data: { action: "input->admin-sso-form#updateCallbackUrl" } %> +
+

Unique identifier (lowercase, numbers, underscores only)

+ +
+ <%= form.text_field :label, + label: "Button Label", + placeholder: "e.g., Sign in with Keycloak", + required: true %> + +
+ <%= form.text_field :icon, + label: "Icon (optional)", + placeholder: "e.g., key, shield" %> +

Lucide icon name for the login button

+
+
+ +
+
+

<%= t("admin.sso_providers.form.enabled_label") %>

+

<%= t("admin.sso_providers.form.enabled_help") %>

+
+ <%= form.toggle :enabled %> +
+
+ +
+

OAuth/OIDC Configuration

+ +
"> + <%= form.text_field :issuer, + label: "Issuer URL", + placeholder: "https://your-idp.example.com/realms/your-realm", + data: { action: "blur->admin-sso-form#validateIssuer" } %> +

OIDC issuer URL (validates .well-known/openid-configuration)

+
+ + <%= form.text_field :client_id, + label: "Client ID", + placeholder: "your-client-id", + required: true %> + + <%= form.password_field :client_secret, + label: "Client Secret", + placeholder: sso_provider.persisted? ? "••••••••" : "your-client-secret", + required: !sso_provider.persisted? %> + <% if sso_provider.persisted? %> +

Leave blank to keep existing secret

+ <% end %> + +
"> + +
+ <%= "#{request.base_url}/auth/#{sso_provider.name.presence || 'PROVIDER_NAME'}/callback" %> + +
+

Configure this URL in your identity provider

+
+
+ +
"> +

<%= t("admin.sso_providers.form.saml_configuration") %>

+ +
+ + " + class="w-full px-3 py-2 border border-primary rounded-lg text-sm" + placeholder="https://idp.example.com/metadata" + autocomplete="off"> +

<%= t("admin.sso_providers.form.idp_metadata_url_help") %>

+
+ +
+ <%= t("admin.sso_providers.form.manual_saml_config") %> +
+

<%= t("admin.sso_providers.form.manual_saml_help") %>

+ +
+ + " + class="w-full px-3 py-2 border border-primary rounded-lg text-sm" + placeholder="https://idp.example.com/sso" + autocomplete="off"> +
+ +
+ + " + class="w-full px-3 py-2 border border-primary rounded-lg text-sm" + placeholder="https://idp.example.com/slo (optional)" + autocomplete="off"> +
+ +
+ + +

<%= t("admin.sso_providers.form.idp_certificate_help") %>

+
+ +
+ + " + class="w-full px-3 py-2 border border-primary rounded-lg text-sm font-mono" + placeholder="AB:CD:EF:..." + autocomplete="off"> +
+ +
+ + +
+
+
+ +
+ +
+ <%= "#{request.base_url}/auth/#{sso_provider.name.presence || 'PROVIDER_NAME'}/callback" %> + +
+

Configure this URL as the Assertion Consumer Service URL in your IdP

+
+
+ +
+

<%= t("admin.sso_providers.form.provisioning_title") %>

+ + <%= form.select "settings[default_role]", + options_for_select([ + [t("admin.sso_providers.form.role_member"), "member"], + [t("admin.sso_providers.form.role_admin"), "admin"], + [t("admin.sso_providers.form.role_super_admin"), "super_admin"] + ], sso_provider.settings&.dig("default_role") || "member"), + { label: t("admin.sso_providers.form.default_role_label"), include_blank: false } %> +

<%= t("admin.sso_providers.form.default_role_help") %>

+ +
+ <%= t("admin.sso_providers.form.role_mapping_title") %> +
+

<%= t("admin.sso_providers.form.role_mapping_help") %>

+ +
+ + " + class="w-full px-3 py-2 border border-primary rounded-lg text-sm" + placeholder="Platform-Admins, IdP-Superusers" + autocomplete="off"> +

<%= t("admin.sso_providers.form.groups_help") %>

+
+ +
+ + " + class="w-full px-3 py-2 border border-primary rounded-lg text-sm" + placeholder="Team-Leads, Managers" + autocomplete="off"> +
+ +
+ + " + class="w-full px-3 py-2 border border-primary rounded-lg text-sm" + placeholder="* (all groups)" + autocomplete="off"> +
+
+
+
+ +
"> +

<%= t("admin.sso_providers.form.advanced_title") %>

+ +
+ <%= form.text_field "settings[scopes]", + label: t("admin.sso_providers.form.scopes_label"), + value: sso_provider.settings&.dig("scopes"), + placeholder: "openid email profile groups" %> +

<%= t("admin.sso_providers.form.scopes_help") %>

+
+ + <%= form.select "settings[prompt]", + options_for_select([ + [t("admin.sso_providers.form.prompt_default"), ""], + [t("admin.sso_providers.form.prompt_login"), "login"], + [t("admin.sso_providers.form.prompt_consent"), "consent"], + [t("admin.sso_providers.form.prompt_select_account"), "select_account"], + [t("admin.sso_providers.form.prompt_none"), "none"] + ], sso_provider.settings&.dig("prompt")), + { label: t("admin.sso_providers.form.prompt_label"), include_blank: false } %> +

<%= t("admin.sso_providers.form.prompt_help") %>

+
+ +
+
+ <% if sso_provider.persisted? %> + + + <% end %> +
+ +
+ <%= link_to "Cancel", admin_sso_providers_path, class: "px-4 py-2 text-sm font-medium text-secondary hover:text-primary" %> + <%= form.submit sso_provider.persisted? ? "Update Provider" : "Create Provider", + class: "px-4 py-2 bg-primary text-inverse rounded-lg text-sm font-medium hover:bg-primary/90" %> +
+
+<% end %> diff --git a/app/views/admin/sso_providers/edit.html.erb b/app/views/admin/sso_providers/edit.html.erb new file mode 100644 index 000000000..7fc531668 --- /dev/null +++ b/app/views/admin/sso_providers/edit.html.erb @@ -0,0 +1,9 @@ +<%= content_for :page_title, "Edit #{@sso_provider.label}" %> + +
+

Update configuration for <%= @sso_provider.label %>.

+ + <%= settings_section title: "Provider Configuration" do %> + <%= render "form", sso_provider: @sso_provider %> + <% end %> +
diff --git a/app/views/admin/sso_providers/index.html.erb b/app/views/admin/sso_providers/index.html.erb new file mode 100644 index 000000000..006dc2647 --- /dev/null +++ b/app/views/admin/sso_providers/index.html.erb @@ -0,0 +1,126 @@ +<%= content_for :page_title, "SSO Providers" %> + +
+

+ Manage single sign-on authentication providers for your instance. + <% unless Flipper.enabled?(:db_sso_providers) %> + Changes require a server restart to take effect. + <% end %> +

+ + <%= settings_section title: "Configured Providers" do %> + <% if @sso_providers.any? %> +
+ <% @sso_providers.each do |provider| %> +
+
+ <% if provider.icon.present? %> + <%= icon provider.icon, class: "w-5 h-5 text-secondary" %> + <% else %> + <%= icon "key", class: "w-5 h-5 text-secondary" %> + <% end %> +
+

<%= provider.label %>

+

<%= provider.strategy.titleize %> · <%= provider.name %>

+
+
+
+ <% if provider.enabled? %> + + Enabled + + <% else %> + + Disabled + + <% end %> + <%= link_to edit_admin_sso_provider_path(provider), class: "p-1 text-secondary hover:text-primary", title: "Edit" do %> + <%= icon "pencil", class: "w-4 h-4" %> + <% end %> + <%= button_to toggle_admin_sso_provider_path(provider), method: :patch, class: "p-1 text-secondary hover:text-primary", title: provider.enabled? ? "Disable" : "Enable", form: { data: { turbo_confirm: "Are you sure you want to #{provider.enabled? ? 'disable' : 'enable'} this provider?" } } do %> + <%= icon provider.enabled? ? "toggle-right" : "toggle-left", class: "w-4 h-4" %> + <% end %> + <%= button_to admin_sso_provider_path(provider), method: :delete, class: "p-1 text-destructive hover:text-destructive", title: "Delete", form: { data: { turbo_confirm: "Are you sure you want to delete this provider? This action cannot be undone." } } do %> + <%= icon "trash-2", class: "w-4 h-4" %> + <% end %> +
+
+ <% end %> +
+ <% else %> +
+ <%= icon "key", class: "w-12 h-12 mx-auto text-secondary mb-3" %> +

No SSO providers configured yet.

+
+ <% end %> + +
+ <%= link_to new_admin_sso_provider_path, class: "inline-flex items-center gap-2 text-sm font-medium text-primary hover:text-secondary" do %> + <%= icon "plus", class: "w-4 h-4" %> + Add Provider + <% end %> +
+ <% end %> + + <% if @legacy_providers.any? %> + <%= settings_section title: t("admin.sso_providers.index.legacy_providers_title"), collapsible: true, open: true do %> +
+
+ <%= icon "alert-triangle", class: "w-5 h-5 text-amber-600 shrink-0" %> +

+ <%= t("admin.sso_providers.index.legacy_providers_notice") %> +

+
+
+ +
+ <% @legacy_providers.each do |provider| %> +
+
+ <% provider_icon = provider[:icon].presence || "key" %> + <%= icon provider_icon, class: "w-5 h-5 text-secondary" %> +
+

<%= provider[:label].presence || provider[:name] %>

+

+ <%= provider[:strategy].to_s.titleize %> · <%= provider[:name] %> + <% if provider[:issuer].present? %> + · <%= provider[:issuer] %> + <% end %> +

+
+
+
+ + <%= t("admin.sso_providers.index.env_configured") %> + +
+
+ <% end %> +
+ <% end %> + <% end %> + + <%= settings_section title: "Configuration Mode", collapsible: true, open: false do %> +
+
+
+

Database-backed providers

+

Load providers from database instead of YAML config

+
+ <% if Flipper.enabled?(:db_sso_providers) %> + + Enabled + + <% else %> + + Disabled + + <% end %> +
+

+ Set AUTH_PROVIDERS_SOURCE=db to enable database-backed providers. + This allows changes without server restarts. +

+
+ <% end %> +
diff --git a/app/views/admin/sso_providers/new.html.erb b/app/views/admin/sso_providers/new.html.erb new file mode 100644 index 000000000..20be829c8 --- /dev/null +++ b/app/views/admin/sso_providers/new.html.erb @@ -0,0 +1,9 @@ +<%= content_for :page_title, "Add SSO Provider" %> + +
+

Configure a new single sign-on authentication provider.

+ + <%= settings_section title: "Provider Configuration" do %> + <%= render "form", sso_provider: @sso_provider %> + <% end %> +
diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb new file mode 100644 index 000000000..551cd4d10 --- /dev/null +++ b/app/views/admin/users/index.html.erb @@ -0,0 +1,73 @@ +<%= content_for :page_title, t(".title") %> + +
+

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

+ + <%= settings_section title: t(".section_title") do %> +
+ <% @users.each do |user| %> +
+
+
+ <%= user.initials %> +
+
+

<%= user.display_name %>

+

<%= user.email %>

+
+
+
+ <% if user.id == Current.user.id %> + <%= t(".you") %> + + <%= t(".roles.#{user.role}") %> + + <% else %> + <%= form_with model: [:admin, user], method: :patch, class: "flex items-center gap-2" do |form| %> + <%= form.select :role, + options_for_select([ + [t(".roles.member"), "member"], + [t(".roles.admin"), "admin"], + [t(".roles.super_admin"), "super_admin"] + ], user.role), + {}, + class: "text-sm rounded-lg border-primary bg-container text-primary px-2 py-1", + onchange: "this.form.requestSubmit()" %> + <% end %> + <% end %> +
+
+ <% end %> +
+ + <% if @users.empty? %> +
+ <%= icon "users", class: "w-12 h-12 mx-auto text-secondary mb-3" %> +

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

+
+ <% end %> + <% end %> + + <%= settings_section title: t(".role_descriptions_title"), collapsible: true, open: false do %> +
+
+ + <%= t(".roles.member") %> + +

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

+
+
+ + <%= t(".roles.admin") %> + +

<%= t(".role_descriptions.admin") %>

+
+
+ + <%= t(".roles.super_admin") %> + +

<%= t(".role_descriptions.super_admin") %>

+
+
+ <% end %> +
diff --git a/app/views/holdings/_cost_basis_cell.html.erb b/app/views/holdings/_cost_basis_cell.html.erb new file mode 100644 index 000000000..24401b8bb --- /dev/null +++ b/app/views/holdings/_cost_basis_cell.html.erb @@ -0,0 +1,109 @@ +<%# locals: (holding:, editable: true) %> + +<% + # Pre-calculate values for the form + # Note: cost_basis field stores per-share cost, so calculate total for display + current_per_share = holding.cost_basis.present? && holding.cost_basis.positive? ? holding.cost_basis : nil + current_total = current_per_share && holding.qty.positive? ? (current_per_share * holding.qty).round(2) : nil + currency = Money::Currency.new(holding.currency) +%> + +<%= turbo_frame_tag dom_id(holding, :cost_basis) do %> + <% if holding.cost_basis_locked? && !editable %> + <%# Locked and not editable (from holdings list) - just show value, right-aligned %> +
+ <%= tag.span format_money(holding.avg_cost) %> + <%= icon "lock", size: "xs", class: "text-secondary" %> +
+ <% else %> + <%# Unlocked OR editable context (drawer) - show clickable menu %> + <%= render DS::Menu.new(variant: :button, placement: "bottom-end") do |menu| %> + <% menu.with_button(class: "hover:text-primary cursor-pointer group") do %> + <% if holding.avg_cost %> +
+ <%= tag.span format_money(holding.avg_cost) %> + <% if holding.cost_basis_locked? %> + <%= icon "lock", size: "xs", class: "text-secondary" %> + <% end %> + <%= icon "pencil", size: "xs", class: "text-secondary opacity-0 group-hover:opacity-100 transition-opacity" %> +
+ <% else %> +
+ <%= icon "pencil", size: "xs" %> + Set +
+ <% end %> + <% end %> + <% menu.with_custom_content do %> +
+

+ <%= t(".set_cost_basis_header", ticker: holding.ticker, qty: number_with_precision(holding.qty, precision: 2)) %> +

+ <% + form_data = { turbo: false } + if holding.avg_cost + form_data[:turbo_confirm] = { + title: t(".overwrite_confirm_title"), + body: t(".overwrite_confirm_body", current: format_money(holding.avg_cost)) + } + end + %> + <%= styled_form_with model: holding, + url: holding_path(holding), + method: :patch, + class: "space-y-3", + data: form_data do |f| %> + +
+
+ +
+ <%= currency.symbol %> + + <%= currency.iso_code %> +
+
+
+

+ = <%= currency.symbol %><%= number_with_precision(current_per_share, precision: 2) || "0.00" %> <%= t(".per_share") %> +

+ + +
+ +
+ <%= currency.symbol %> + + <%= currency.iso_code %> +
+
+ +
+ + <%= f.submit t(".save"), class: "inline-flex items-center gap-1 px-2 py-1 rounded-md text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover" %> +
+ <% end %> +
+ <% end %> + <% end %> + <% end %> +<% end %> diff --git a/app/views/holdings/_holding.html.erb b/app/views/holdings/_holding.html.erb index 4865af8ee..85d9474ac 100644 --- a/app/views/holdings/_holding.html.erb +++ b/app/views/holdings/_holding.html.erb @@ -31,7 +31,7 @@
- <%= tag.p format_money holding.avg_cost %> + <%= render "holdings/cost_basis_cell", holding: holding, editable: false %> <%= tag.p t(".per_share"), class: "font-normal text-secondary" %>
@@ -45,13 +45,13 @@
- <%# Show Total Return (unrealized G/L) when cost basis exists %> - <% if holding.trades.any? && holding.trend %> + <%# Show Total Return (unrealized G/L) when cost basis exists (from trades or manual) %> + <% if holding.trend %> <%= tag.p format_money(holding.trend.value), style: "color: #{holding.trend.color};" %> <%= tag.p "(#{number_to_percentage(holding.trend.percent, precision: 1)})", style: "color: #{holding.trend.color};" %> <% else %> <%= tag.p "--", class: "text-secondary" %> - <%= tag.p "No cost basis", class: "text-xs text-secondary" %> + <%= tag.p t(".no_cost_basis"), class: "text-xs text-secondary" %> <% end %>
diff --git a/app/views/holdings/show.html.erb b/app/views/holdings/show.html.erb index 32918b49e..868b77f1d 100644 --- a/app/views/holdings/show.html.erb +++ b/app/views/holdings/show.html.erb @@ -35,16 +35,107 @@
<%= @holding.weight ? number_to_percentage(@holding.weight, precision: 2) : t(".unknown") %>
-
-
<%= t(".avg_cost_label") %>
-
<%= @holding.avg_cost ? format_money(@holding.avg_cost) : t(".unknown") %>
+ <%# Average Cost with inline editor %> + <% + currency = Money::Currency.new(@holding.currency) + current_per_share = @holding.cost_basis.present? && @holding.cost_basis.positive? ? @holding.cost_basis : nil + current_total = current_per_share && @holding.qty.positive? ? (current_per_share * @holding.qty).round(2) : nil + %> +
+
+
<%= t(".avg_cost_label") %>
+
+ <%= @holding.avg_cost ? format_money(@holding.avg_cost) : t(".unknown") %> + <% if @holding.cost_basis_locked? %> + <%= icon "lock", size: "xs", class: "text-secondary" %> + <% end %> + <% if @holding.cost_basis_source.present? %> + (<%= @holding.cost_basis_source_label %>) + <% end %> + +
+
+ + <%# Inline cost basis editor (hidden by default) %> +
<%= t(".total_return_label") %>
-
- <%= @holding.trend ? render("shared/trend_change", trend: @holding.trend) : t(".unknown") %> -
+ <% if @holding.trend %> +
+ <%= render("shared/trend_change", trend: @holding.trend) %> +
+ <% else %> +
<%= t(".unknown") %>
+ <% end %>
@@ -85,21 +176,39 @@ <% end %> - <% if @holding.account.can_delete_holdings? %> + <% if @holding.cost_basis_locked? || @holding.account.can_delete_holdings? %> <% dialog.with_section(title: t(".settings"), open: true) do %>
-
-
-

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

-

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

-
+ <% if @holding.cost_basis_locked? %> +
+
+

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

+

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

+
- <%= button_to t(".delete"), - holding_path(@holding), - method: :delete, - class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-secondary", - data: { turbo_confirm: true } %> -
+ <%= button_to t(".unlock_cost_basis"), + unlock_cost_basis_holding_path(@holding), + method: :post, + class: "inline-flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium text-primary bg-gray-200 hover:bg-gray-300 theme-dark:bg-gray-700 theme-dark:hover:bg-gray-600", + form: { data: { turbo: false } }, + data: { turbo_confirm: { title: t(".unlock_confirm_title"), body: t(".unlock_confirm_body") } } %> +
+ <% end %> + + <% if @holding.account.can_delete_holdings? %> +
+
+

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

+

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

+
+ + <%= button_to t(".delete"), + holding_path(@holding), + method: :delete, + class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-secondary", + data: { turbo_confirm: true } %> +
+ <% end %>
<% end %> <% end %> diff --git a/app/views/imports/_drag_drop_overlay.html.erb b/app/views/imports/_drag_drop_overlay.html.erb index 504920a86..e737eb25b 100644 --- a/app/views/imports/_drag_drop_overlay.html.erb +++ b/app/views/imports/_drag_drop_overlay.html.erb @@ -1,7 +1,11 @@ -