diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index cfaf22ece..3a48dc7a8 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -21,7 +21,7 @@ services: - ..:/workspace:cached - bundle_cache:/bundle ports: - - "3000:3000" + - ${PORT:-3000}:3000 command: sleep infinity environment: <<: *rails_env diff --git a/.env.example b/.env.example index 56568fc88..548bd1971 100644 --- a/.env.example +++ b/.env.example @@ -103,7 +103,8 @@ POSTHOG_HOST= # Active Record Encryption Keys (Optional) # These keys are used to encrypt sensitive data like API keys in the database. -# If not provided, they will be automatically generated based on your SECRET_KEY_BASE. +# For managed mode: Set these environment variables to provide encryption keys. +# For self-hosted mode: If not provided, they will be automatically generated based on your SECRET_KEY_BASE. # You can generate your own keys by running: rails db:encryption:init # ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY= # ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY= diff --git a/.env.local.example b/.env.local.example index 9bcaf3da4..e91e7cf0e 100644 --- a/.env.local.example +++ b/.env.local.example @@ -1,6 +1,10 @@ # To enable / disable self-hosting features. SELF_HOSTED = true +# Custom port config +# For users who have other applications listening at 3000, this allows them to set a value puma will listen to. +PORT=3000 + # SimpleFIN runtime flags (default-off) # Accepted truthy values: 1, true, yes, on # SIMPLEFIN_DEBUG_RAW: when truthy, logs the raw payload returned by SimpleFIN (debug-only; can be noisy) diff --git a/.env.test.example b/.env.test.example index 14a75ea3e..9b0fbb62b 100644 --- a/.env.test.example +++ b/.env.test.example @@ -1,5 +1,9 @@ SELF_HOSTED=false +# Custom port config +# For users who have other applications listening at 3000, this allows them to set a value puma will listen to. +PORT=3000 + # SimpleFIN runtime flags (default-off) # Accepted truthy values: 1, true, yes, on # SIMPLEFIN_DEBUG_RAW: when truthy, logs the raw payload returned by SimpleFIN (debug-only; can be noisy) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 440bb39a4..2c3479071 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -329,9 +329,9 @@ jobs: > **Note**: These are debug builds intended for testing purposes. For production use, please build from source with proper signing credentials. - bump-alpha-version: - name: Bump Alpha Version - if: startsWith(github.ref, 'refs/tags/v') && contains(github.ref_name, 'alpha') + bump-pre_release-version: + name: Bump Pre-release Version + if: startsWith(github.ref, 'refs/tags/v') && (contains(github.ref_name, 'alpha') || contains(github.ref_name, 'beta') || contains(github.ref_name, 'rc')) needs: [merge] runs-on: ubuntu-latest timeout-minutes: 10 @@ -346,7 +346,7 @@ jobs: ref: main token: ${{ secrets.GH_PAT }} - - name: Bump alpha version + - name: Bump pre-release version run: | VERSION_FILE="config/initializers/version.rb" @@ -357,20 +357,26 @@ jobs: fi # Extract current version - CURRENT_VERSION=$(grep -oP '"\K[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+' "$VERSION_FILE") + CURRENT_VERSION=$(grep -oP '"\K[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc)\.[0-9]+' "$VERSION_FILE") if [ -z "$CURRENT_VERSION" ]; then echo "ERROR: Could not extract version from $VERSION_FILE" exit 1 fi echo "Current version: $CURRENT_VERSION" - - # Extract the alpha number and increment it - ALPHA_NUM=$(echo "$CURRENT_VERSION" | grep -oP 'alpha\.\K[0-9]+') - if [ -z "$ALPHA_NUM" ]; then - echo "ERROR: Could not extract alpha number from $CURRENT_VERSION" + + # Extract the pre-release tag and number, then increment it + PRE_RELEASE_TAG=$(echo "$CURRENT_VERSION" | grep -oP '(alpha|beta|rc)') + if [ -z "$PRE_RELEASE_TAG" ]; then + echo "ERROR: Could not extract pre-release tag from $CURRENT_VERSION" exit 1 fi - NEW_ALPHA_NUM=$((ALPHA_NUM + 1)) + + PRE_RELEASE_NUM=$(echo "$CURRENT_VERSION" | grep -oP '(alpha|beta|rc)\.\K[0-9]+') + if [ -z "$PRE_RELEASE_NUM" ]; then + echo "ERROR: Could not extract pre-release number from $CURRENT_VERSION" + exit 1 + fi + NEW_PRE_RELEASE_NUM=$((PRE_RELEASE_NUM + 1)) # Create new version string BASE_VERSION=$(echo "$CURRENT_VERSION" | grep -oP '^[0-9]+\.[0-9]+\.[0-9]+') @@ -378,7 +384,7 @@ jobs: echo "ERROR: Could not extract base version from $CURRENT_VERSION" exit 1 fi - NEW_VERSION="${BASE_VERSION}-alpha.${NEW_ALPHA_NUM}" + NEW_VERSION="${BASE_VERSION}-${PRE_RELEASE_TAG}.${NEW_PRE_RELEASE_NUM}" echo "New version: $NEW_VERSION" # Update the version file @@ -401,7 +407,7 @@ jobs: exit 0 fi - git commit -m "Bump version to next alpha after ${{ github.ref_name }} release" + git commit -m "Bump version to next iteration after ${{ github.ref_name }} release" # Push with retry logic attempts=0 diff --git a/README.md b/README.md index f7580195a..81d509be7 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,8 @@ For further instructions, see guides below. [![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=sure) -[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/sure?referralCode=CW_fPQ) +[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/T_draF?referralCode=CW_fPQ) + ## License and Trademarks diff --git a/app/controllers/concerns/localize.rb b/app/controllers/concerns/localize.rb index 2c5a19646..db66819c3 100644 --- a/app/controllers/concerns/localize.rb +++ b/app/controllers/concerns/localize.rb @@ -8,12 +8,104 @@ module Localize private def switch_locale(&action) - locale = locale_from_param || Current.family.try(:locale) || I18n.default_locale + locale = locale_from_param || locale_from_user || locale_from_accept_language || locale_from_family || I18n.default_locale I18n.with_locale(locale, &action) end + def locale_from_user + locale = Current.user&.locale + return if locale.blank? + + locale_sym = locale.to_sym + locale_sym if I18n.available_locales.include?(locale_sym) + end + + def locale_from_family + locale = Current.family&.locale + return if locale.blank? + + locale_sym = locale.to_sym + locale_sym if I18n.available_locales.include?(locale_sym) + end + + def locale_from_accept_language + locale = accept_language_top_locale + return if locale.blank? + + locale_sym = locale.to_sym + return unless I18n.available_locales.include?(locale_sym) + + # Auto-save detected locale to user profile (once per user, not per session) + if Current.user.present? && Current.user.locale.blank? + Current.user.update_column(:locale, locale_sym.to_s) + end + + locale_sym + end + + def accept_language_top_locale + header = request.get_header("HTTP_ACCEPT_LANGUAGE") + return if header.blank? + + # Parse language;q pairs and sort by q-value (descending), preserving header order for ties + parsed_languages = parse_accept_language(header) + return if parsed_languages.empty? + + # Find first supported locale by q-value priority + parsed_languages.each do |lang, _q| + normalized = normalize_locale(lang) + canonical = supported_locales[normalized.downcase] + return canonical if canonical.present? + + primary_language = normalized.split("-").first + primary_match = supported_locales[primary_language.downcase] + return primary_match if primary_match.present? + end + + nil + end + + def parse_accept_language(header) + entries = [] + + header.split(",").each_with_index do |entry, index| + parts = entry.split(";") + language = parts.first.to_s.strip + next if language.blank? + + # Extract q-value, default to 1.0 + q_value = 1.0 + parts[1..].each do |param| + param = param.strip + if param.start_with?("q=") + q_str = param[2..] + q_value = Float(q_str) rescue 1.0 + q_value = q_value.clamp(0.0, 1.0) + break + end + end + + entries << [ language, q_value, index ] + end + + # Sort by q-value descending, then by original header order ascending + entries.sort_by { |_lang, q, idx| [ -q, idx ] }.map { |lang, q, _idx| [ lang, q ] } + end + + def supported_locales + @supported_locales ||= LanguagesHelper::SUPPORTED_LOCALES.each_with_object({}) do |locale, locales| + normalized = normalize_locale(locale) + locales[normalized.downcase] = normalized + end + end + + def normalize_locale(locale) + locale.to_s.strip.gsub("_", "-") + end + def locale_from_param return unless params[:locale].is_a?(String) && params[:locale].present? + locale = params[:locale].to_sym locale if I18n.available_locales.include?(locale) end diff --git a/app/controllers/import/configurations_controller.rb b/app/controllers/import/configurations_controller.rb index e17aa138e..12e47a477 100644 --- a/app/controllers/import/configurations_controller.rb +++ b/app/controllers/import/configurations_controller.rb @@ -46,6 +46,7 @@ class Import::ConfigurationsController < ApplicationController :number_format, :signage_convention, :amount_type_strategy, + :amount_type_identifier_value, :amount_type_inflow_value, :rows_to_skip ) diff --git a/app/controllers/settings/providers_controller.rb b/app/controllers/settings/providers_controller.rb index 196acfca4..e93cb1695 100644 --- a/app/controllers/settings/providers_controller.rb +++ b/app/controllers/settings/providers_controller.rb @@ -10,6 +10,9 @@ class Settings::ProvidersController < ApplicationController ] prepare_show_context + rescue ActiveRecord::Encryption::Errors::Configuration => e + Rails.logger.error("Active Record Encryption not configured: #{e.message}") + @encryption_error = true end def update diff --git a/app/controllers/trades_controller.rb b/app/controllers/trades_controller.rb index f131c47ef..e3061a719 100644 --- a/app/controllers/trades_controller.rb +++ b/app/controllers/trades_controller.rb @@ -1,6 +1,8 @@ class TradesController < ApplicationController include EntryableResource + before_action :set_entry_for_unlock, only: :unlock + # Defaults to a buy trade def new @account = Current.family.accounts.find_by(id: params[:account_id]) @@ -17,6 +19,12 @@ class TradesController < ApplicationController @model = Trade::CreateForm.new(create_params.merge(account: @account)).create if @model.persisted? + # Mark manually created entries as user-modified to protect from sync + if @model.is_a?(Entry) + @model.lock_saved_attributes! + @model.mark_user_modified! + end + flash[:notice] = t("entries.create.success") respond_to do |format| @@ -30,19 +38,28 @@ class TradesController < ApplicationController def update if @entry.update(update_entry_params) + @entry.lock_saved_attributes! @entry.mark_user_modified! @entry.sync_account_later + # Reload to ensure fresh state for turbo stream rendering + @entry.reload + respond_to do |format| format.html { redirect_back_or_to account_path(@entry.account), notice: t("entries.update.success") } format.turbo_stream do render turbo_stream: [ turbo_stream.replace( - "header_entry_#{@entry.id}", + dom_id(@entry, :header), partial: "trades/header", locals: { entry: @entry } ), - turbo_stream.replace("entry_#{@entry.id}", partial: "entries/entry", locals: { entry: @entry }) + turbo_stream.replace( + dom_id(@entry, :protection), + partial: "entries/protection_indicator", + locals: { entry: @entry, unlock_path: unlock_trade_path(@entry.trade) } + ), + turbo_stream.replace(@entry) ] end end @@ -51,7 +68,19 @@ class TradesController < ApplicationController end end + def unlock + @entry.unlock_for_sync! + flash[:notice] = t("entries.unlock.success") + + redirect_back_or_to account_path(@entry.account) + end + private + def set_entry_for_unlock + trade = Current.family.trades.find(params[:id]) + @entry = trade.entry + end + def entry_params params.require(:entry).permit( :name, :date, :amount, :currency, :excluded, :notes, :nature, diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index c288c1986..a2210f3f6 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -1,6 +1,7 @@ class TransactionsController < ApplicationController include EntryableResource + before_action :set_entry_for_unlock, only: :unlock before_action :store_params!, only: :index def new @@ -68,6 +69,7 @@ class TransactionsController < ApplicationController if @entry.save @entry.sync_account_later @entry.lock_saved_attributes! + @entry.mark_user_modified! @entry.transaction.lock_attr!(:tag_ids) if @entry.transaction.tags.any? flash[:notice] = "Transaction created" @@ -98,6 +100,9 @@ class TransactionsController < ApplicationController @entry.transaction.lock_attr!(:tag_ids) if @entry.transaction.tags.any? @entry.sync_account_later + # Reload to ensure fresh state for turbo stream rendering + @entry.reload + respond_to do |format| format.html { redirect_back_or_to account_path(@entry.account), notice: "Transaction updated" } format.turbo_stream do @@ -107,6 +112,11 @@ class TransactionsController < ApplicationController partial: "transactions/header", locals: { entry: @entry } ), + turbo_stream.replace( + dom_id(@entry, :protection), + partial: "entries/protection_indicator", + locals: { entry: @entry, unlock_path: unlock_transaction_path(@entry.transaction) } + ), turbo_stream.replace(@entry), *flash_notification_stream_items ] @@ -206,7 +216,7 @@ class TransactionsController < ApplicationController original_name: @entry.name, original_date: I18n.l(@entry.date, format: :long)) - @entry.account.entries.create!( + new_entry = @entry.account.entries.create!( name: params[:trade_name] || Trade.build_name(is_sell ? "sell" : "buy", qty, security.ticker), date: @entry.date, amount: signed_amount, @@ -221,6 +231,10 @@ class TransactionsController < ApplicationController ) ) + # Mark the new trade as user-modified to protect from sync + new_entry.lock_saved_attributes! + new_entry.mark_user_modified! + # Mark original transaction as excluded (soft delete) @entry.update!(excluded: true) end @@ -235,6 +249,13 @@ class TransactionsController < ApplicationController redirect_back_or_to transactions_path, status: :see_other end + def unlock + @entry.unlock_for_sync! + flash[:notice] = t("entries.unlock.success") + + redirect_back_or_to transactions_path + end + def mark_as_recurring transaction = Current.family.transactions.includes(entry: :account).find(params[:id]) @@ -286,6 +307,11 @@ class TransactionsController < ApplicationController end private + def set_entry_for_unlock + transaction = Current.family.transactions.find(params[:id]) + @entry = transaction.entry + end + def needs_rule_notification?(transaction) return false if Current.user.rule_prompts_disabled diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 067abbd25..59fa68bdb 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -105,8 +105,8 @@ class UsersController < ApplicationController def user_params params.require(:user).permit( :first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, - :show_sidebar, :default_period, :default_account_order, :show_ai_sidebar, :ai_enabled, :theme, :set_onboarding_preferences_at, :set_onboarding_goals_at, - family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id ], + :show_sidebar, :default_period, :default_account_order, :show_ai_sidebar, :ai_enabled, :theme, :set_onboarding_preferences_at, :set_onboarding_goals_at, :locale, + family_attributes: [ :name, :currency, :country, :date_format, :timezone, :locale, :id ], goals: [] ) end diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index af71fcd1a..f4de696c2 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -44,9 +44,9 @@ module SettingsHelper } end - def settings_section(title:, subtitle: nil, collapsible: false, open: true, &block) + def settings_section(title:, subtitle: nil, collapsible: false, open: true, auto_open_param: nil, &block) content = capture(&block) - render partial: "settings/section", locals: { title: title, subtitle: subtitle, content: content, collapsible: collapsible, open: open } + render partial: "settings/section", locals: { title: title, subtitle: subtitle, content: content, collapsible: collapsible, open: open, auto_open_param: auto_open_param } end def settings_nav_footer diff --git a/app/javascript/controllers/auto_open_controller.js b/app/javascript/controllers/auto_open_controller.js new file mode 100644 index 000000000..de59f108e --- /dev/null +++ b/app/javascript/controllers/auto_open_controller.js @@ -0,0 +1,29 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="auto-open" +// Auto-opens a
element based on URL param +// Use data-auto-open-param-value="paramName" to open when ?paramName=1 is in URL +export default class extends Controller { + static values = { param: String }; + + connect() { + if (!this.hasParamValue || !this.paramValue) return; + + const params = new URLSearchParams(window.location.search); + if (params.get(this.paramValue) === "1") { + this.element.open = true; + + // Clean up the URL param after opening + params.delete(this.paramValue); + const newUrl = params.toString() + ? `${window.location.pathname}?${params.toString()}${window.location.hash}` + : `${window.location.pathname}${window.location.hash}`; + window.history.replaceState({}, "", newUrl); + + // Scroll into view after opening + requestAnimationFrame(() => { + this.element.scrollIntoView({ behavior: "smooth", block: "start" }); + }); + } + } +} diff --git a/app/javascript/controllers/import_controller.js b/app/javascript/controllers/import_controller.js index b75d4cd6a..b1f238036 100644 --- a/app/javascript/controllers/import_controller.js +++ b/app/javascript/controllers/import_controller.js @@ -11,6 +11,7 @@ export default class extends Controller { "signedAmountFieldset", "customColumnFieldset", "amountTypeValue", + "amountTypeInflowValue", "amountTypeStrategySelect", ]; @@ -20,6 +21,9 @@ export default class extends Controller { this.amountTypeColumnKeyValue ) { this.#showAmountTypeValueTargets(this.amountTypeColumnKeyValue); + if (this.amountTypeValueTarget.querySelector("select")?.value) { + this.#showAmountTypeInflowValueTargets(); + } } } @@ -31,6 +35,9 @@ export default class extends Controller { if (this.amountTypeColumnKeyValue) { this.#showAmountTypeValueTargets(this.amountTypeColumnKeyValue); + if (this.amountTypeValueTarget.querySelector("select")?.value) { + this.#showAmountTypeInflowValueTargets(); + } } } @@ -43,6 +50,11 @@ export default class extends Controller { const amountTypeColumnKey = event.target.value; this.#showAmountTypeValueTargets(amountTypeColumnKey); + this.#showAmountTypeInflowValueTargets(); + } + + handleAmountTypeIdentifierChange(event) { + this.#showAmountTypeInflowValueTargets(); } refreshForm(event) { @@ -91,6 +103,29 @@ export default class extends Controller { select.appendChild(fragment); } + #showAmountTypeInflowValueTargets() { + // Called when amount_type_identifier_value changes + // Updates the displayed identifier value in the UI text and shows/hides the inflow value dropdown + const identifierValueSelect = this.amountTypeValueTarget.querySelector("select"); + const selectedValue = identifierValueSelect.value; + + if (!selectedValue) { + this.amountTypeInflowValueTarget.classList.add("hidden"); + this.amountTypeInflowValueTarget.classList.remove("flex"); + return; + } + + // Show the inflow value dropdown + this.amountTypeInflowValueTarget.classList.remove("hidden"); + this.amountTypeInflowValueTarget.classList.add("flex"); + + // Update the displayed identifier value in the text + const identifierSpan = this.amountTypeInflowValueTarget.querySelector("span.font-medium"); + if (identifierSpan) { + identifierSpan.textContent = selectedValue; + } + } + #uniqueValuesForColumn(column) { const colIdx = this.csvValue[0].indexOf(column); const values = this.csvValue.slice(1).map((row) => row[colIdx]); @@ -120,6 +155,11 @@ export default class extends Controller { this.customColumnFieldsetTarget.classList.add("hidden"); this.signedAmountFieldsetTarget.classList.remove("hidden"); + // Hide the inflow value targets when using signed amount strategy + this.amountTypeValueTarget.classList.add("hidden"); + this.amountTypeValueTarget.classList.remove("flex"); + this.amountTypeInflowValueTarget.classList.add("hidden"); + this.amountTypeInflowValueTarget.classList.remove("flex"); // Remove required from custom column fields this.customColumnFieldsetTarget .querySelectorAll("select, input") diff --git a/app/javascript/controllers/lazy_load_controller.js b/app/javascript/controllers/lazy_load_controller.js index f210abfa6..93bea5c30 100644 --- a/app/javascript/controllers/lazy_load_controller.js +++ b/app/javascript/controllers/lazy_load_controller.js @@ -3,11 +3,26 @@ import { Controller } from "@hotwired/stimulus"; // Connects to data-controller="lazy-load" // Used with
elements to lazy-load content when expanded // Use data-action="toggle->lazy-load#toggled" on the
element +// Optional: data-lazy-load-auto-open-param-value="paramName" to auto-open when ?paramName=1 is in URL export default class extends Controller { static targets = ["content", "loading", "frame"]; - static values = { url: String, loaded: Boolean }; + static values = { url: String, loaded: Boolean, autoOpenParam: String }; connect() { + // Check if we should auto-open based on URL param + if (this.hasAutoOpenParamValue && this.autoOpenParamValue) { + const params = new URLSearchParams(window.location.search); + if (params.get(this.autoOpenParamValue) === "1") { + this.element.open = true; + // Clean up the URL param after opening + params.delete(this.autoOpenParamValue); + const newUrl = params.toString() + ? `${window.location.pathname}?${params.toString()}${window.location.hash}` + : `${window.location.pathname}${window.location.hash}`; + window.history.replaceState({}, "", newUrl); + } + } + // If already open on connect (browser restored state), load immediately if (this.element.open && !this.loadedValue) { this.load(); diff --git a/app/models/api_key.rb b/app/models/api_key.rb index e966c7f67..2e190500e 100644 --- a/app/models/api_key.rb +++ b/app/models/api_key.rb @@ -1,8 +1,12 @@ class ApiKey < ApplicationRecord + include Encryptable + belongs_to :user - # Use Rails built-in encryption for secure storage - encrypts :display_key, deterministic: true + # Encrypt display_key if ActiveRecord encryption is configured + if encryption_ready? + encrypts :display_key, deterministic: true + end # Constants SOURCES = [ "web", "mobile" ].freeze diff --git a/app/models/concerns/encryptable.rb b/app/models/concerns/encryptable.rb new file mode 100644 index 000000000..0ec5ae923 --- /dev/null +++ b/app/models/concerns/encryptable.rb @@ -0,0 +1,16 @@ +module Encryptable + extend ActiveSupport::Concern + + class_methods do + # Helper to detect if ActiveRecord Encryption is configured for this app. + # This allows encryption to be optional - if not configured, sensitive fields + # are stored in plaintext (useful for development or legacy deployments). + def encryption_ready? + creds_ready = Rails.application.credentials.active_record_encryption.present? + env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? && + ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? && + ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present? + creds_ready || env_ready + end + end +end diff --git a/app/models/enable_banking_account.rb b/app/models/enable_banking_account.rb index 6bc356bc9..ad1293a21 100644 --- a/app/models/enable_banking_account.rb +++ b/app/models/enable_banking_account.rb @@ -1,5 +1,11 @@ class EnableBankingAccount < ApplicationRecord - include CurrencyNormalizable + include CurrencyNormalizable, Encryptable + + # Encrypt raw payloads if ActiveRecord encryption is configured + if encryption_ready? + encrypts :raw_payload + encrypts :raw_transactions_payload + end belongs_to :enable_banking_item diff --git a/app/models/enable_banking_item.rb b/app/models/enable_banking_item.rb index 501df1632..8e84cdb62 100644 --- a/app/models/enable_banking_item.rb +++ b/app/models/enable_banking_item.rb @@ -1,21 +1,14 @@ class EnableBankingItem < ApplicationRecord - include Syncable, Provided, Unlinking + include Syncable, Provided, Unlinking, Encryptable enum :status, { good: "good", requires_update: "requires_update" }, default: :good - # Helper to detect if ActiveRecord Encryption is configured for this app - def self.encryption_ready? - creds_ready = Rails.application.credentials.active_record_encryption.present? - env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? && - ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? && - ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present? - creds_ready || env_ready - end - - # Encrypt sensitive credentials if ActiveRecord encryption is configured + # Encrypt sensitive credentials and raw payloads if ActiveRecord encryption is configured if encryption_ready? encrypts :client_certificate, deterministic: true encrypts :session_id, deterministic: true + encrypts :raw_payload + encrypts :raw_institution_payload end validates :name, presence: true diff --git a/app/models/entry.rb b/app/models/entry.rb index 1436eb545..7f83d0d2c 100644 --- a/app/models/entry.rb +++ b/app/models/entry.rb @@ -265,6 +265,50 @@ class Entry < ApplicationRecord update!(user_modified: true) end + # Returns the reason this entry is protected from sync, or nil if not protected. + # Priority: excluded > user_modified > import_locked + # + # @return [Symbol, nil] :excluded, :user_modified, :import_locked, or nil + def protection_reason + return :excluded if excluded? + return :user_modified if user_modified? + return :import_locked if import_locked? + nil + end + + # Returns array of field names that are locked on entry and entryable. + # + # @return [Array] locked field names + def locked_field_names + entry_keys = locked_attributes&.keys || [] + entryable_keys = entryable&.locked_attributes&.keys || [] + (entry_keys + entryable_keys).uniq + end + + # Returns hash of locked field names to their lock timestamps. + # Combines locked_attributes from both entry and entryable. + # Parses ISO8601 timestamps stored in locked_attributes. + # + # @return [Hash{String => Time}] field name to lock timestamp + def locked_fields_with_timestamps + combined = (locked_attributes || {}).merge(entryable&.locked_attributes || {}) + combined.transform_values do |timestamp| + Time.zone.parse(timestamp.to_s) rescue timestamp + end + end + + # Clears protection flags so provider sync can update this entry again. + # Clears user_modified, import_locked flags, and all locked_attributes + # on both the entry and its entryable. + # + # @return [void] + def unlock_for_sync! + self.class.transaction do + update!(user_modified: false, import_locked: false, locked_attributes: {}) + entryable&.update!(locked_attributes: {}) + end + end + class << self def search(params) EntrySearch.new(params).build_query(all) diff --git a/app/models/family/subscribeable.rb b/app/models/family/subscribeable.rb index fdbbaab8b..de75bbe0c 100644 --- a/app/models/family/subscribeable.rb +++ b/app/models/family/subscribeable.rb @@ -53,6 +53,10 @@ module Family::Subscribeable subscription&.current_period_ends_at end + def subscription_pending_cancellation? + subscription&.pending_cancellation? + end + def start_subscription!(stripe_subscription_id) if subscription.present? subscription.update!(status: "active", stripe_id: stripe_subscription_id) diff --git a/app/models/import.rb b/app/models/import.rb index c01023803..141a1ce05 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -40,6 +40,7 @@ class Import < ApplicationRecord validates :col_sep, inclusion: { in: SEPARATORS.map(&:last) } validates :signage_convention, inclusion: { in: SIGNAGE_CONVENTIONS }, allow_nil: true validates :number_format, presence: true, inclusion: { in: NUMBER_FORMATS.keys } + validate :custom_column_import_requires_identifier validates :rows_to_skip, numericality: { only_integer: true, greater_than_or_equal_to: 0 } validate :account_belongs_to_family validate :rows_to_skip_within_file_bounds @@ -305,6 +306,14 @@ class Import < ApplicationRecord self.number_format ||= "1,234.56" # Default to US/UK format end + def custom_column_import_requires_identifier + return unless amount_type_strategy == "custom_column" + + if amount_type_inflow_value.blank? + errors.add(:base, I18n.t("imports.errors.custom_column_requires_inflow")) + end + 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 diff --git a/app/models/import/row.rb b/app/models/import/row.rb index 26525b6f4..6b68626bd 100644 --- a/app/models/import/row.rb +++ b/app/models/import/row.rb @@ -47,12 +47,27 @@ class Import::Row < ApplicationRecord if import.amount_type_strategy == "signed_amount" value * (import.signage_convention == "inflows_positive" ? -1 : 1) elsif import.amount_type_strategy == "custom_column" - inflow_value = import.amount_type_inflow_value + legacy_identifier = import.amount_type_inflow_value + selected_identifier = + if import.amount_type_identifier_value.present? + import.amount_type_identifier_value + else + legacy_identifier + end - if entity_type == inflow_value - value * -1 + inflow_treatment = + if import.amount_type_inflow_value.in?(%w[inflows_positive inflows_negative]) + import.amount_type_inflow_value + elsif import.signage_convention.in?(%w[inflows_positive inflows_negative]) + import.signage_convention + else + "inflows_positive" + end + + if entity_type == selected_identifier + value * (inflow_treatment == "inflows_positive" ? -1 : 1) else - value + value * (inflow_treatment == "inflows_positive" ? 1 : -1) end else raise "Unknown amount type strategy for import: #{import.amount_type_strategy}" diff --git a/app/models/invitation.rb b/app/models/invitation.rb index caf3e543e..fbdc6554d 100644 --- a/app/models/invitation.rb +++ b/app/models/invitation.rb @@ -1,7 +1,15 @@ class Invitation < ApplicationRecord + include Encryptable + belongs_to :family belongs_to :inviter, class_name: "User" + # Encrypt sensitive fields if ActiveRecord encryption is configured + if encryption_ready? + encrypts :token, deterministic: true + encrypts :email, deterministic: true, downcase: true + end + validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } validates :role, presence: true, inclusion: { in: %w[admin member] } validates :token, presence: true, uniqueness: true diff --git a/app/models/invite_code.rb b/app/models/invite_code.rb index f2cbcd7e0..1fa3e2fcb 100644 --- a/app/models/invite_code.rb +++ b/app/models/invite_code.rb @@ -1,4 +1,11 @@ class InviteCode < ApplicationRecord + include Encryptable + + # Encrypt token if ActiveRecord encryption is configured + if encryption_ready? + encrypts :token, deterministic: true, downcase: true + end + before_validation :generate_token, on: :create class << self diff --git a/app/models/lunchflow_account.rb b/app/models/lunchflow_account.rb index 35d74dab9..c7ce80f7e 100644 --- a/app/models/lunchflow_account.rb +++ b/app/models/lunchflow_account.rb @@ -1,5 +1,11 @@ class LunchflowAccount < ApplicationRecord - include CurrencyNormalizable + include CurrencyNormalizable, Encryptable + + # Encrypt raw payloads if ActiveRecord encryption is configured + if encryption_ready? + encrypts :raw_payload + encrypts :raw_transactions_payload + end belongs_to :lunchflow_item diff --git a/app/models/lunchflow_item.rb b/app/models/lunchflow_item.rb index a2af0fa25..9f4f4cc7a 100644 --- a/app/models/lunchflow_item.rb +++ b/app/models/lunchflow_item.rb @@ -1,20 +1,13 @@ class LunchflowItem < ApplicationRecord - include Syncable, Provided, Unlinking + include Syncable, Provided, Unlinking, Encryptable enum :status, { good: "good", requires_update: "requires_update" }, default: :good - # Helper to detect if ActiveRecord Encryption is configured for this app - def self.encryption_ready? - creds_ready = Rails.application.credentials.active_record_encryption.present? - env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? && - ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? && - ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present? - creds_ready || env_ready - end - - # Encrypt sensitive credentials if ActiveRecord encryption is configured (credentials OR env vars) + # Encrypt sensitive credentials and raw payloads if ActiveRecord encryption is configured if encryption_ready? encrypts :api_key, deterministic: true + encrypts :raw_payload + encrypts :raw_institution_payload end validates :name, presence: true diff --git a/app/models/mobile_device.rb b/app/models/mobile_device.rb index 3e3f5f774..da94291c1 100644 --- a/app/models/mobile_device.rb +++ b/app/models/mobile_device.rb @@ -1,4 +1,11 @@ class MobileDevice < ApplicationRecord + include Encryptable + + # Encrypt device_id if ActiveRecord encryption is configured + if encryption_ready? + encrypts :device_id, deterministic: true + end + belongs_to :user belongs_to :oauth_application, class_name: "Doorkeeper::Application", optional: true diff --git a/app/models/plaid_account.rb b/app/models/plaid_account.rb index 4217a7926..bb5237586 100644 --- a/app/models/plaid_account.rb +++ b/app/models/plaid_account.rb @@ -1,4 +1,15 @@ class PlaidAccount < ApplicationRecord + include Encryptable + + # Encrypt raw payloads if ActiveRecord encryption is configured + if encryption_ready? + encrypts :raw_payload + encrypts :raw_transactions_payload + # Support reading data encrypted under the old column name after rename + encrypts :raw_holdings_payload, previous: { attribute: :raw_investments_payload } + encrypts :raw_liabilities_payload + end + belongs_to :plaid_item # Legacy association via foreign key (will be removed after migration) @@ -38,9 +49,9 @@ class PlaidAccount < ApplicationRecord save! end - def upsert_plaid_investments_snapshot!(investments_snapshot) + def upsert_plaid_holdings_snapshot!(holdings_snapshot) assign_attributes( - raw_investments_payload: investments_snapshot + raw_holdings_payload: holdings_snapshot ) save! diff --git a/app/models/plaid_account/importer.rb b/app/models/plaid_account/importer.rb index 10ae10840..f12c10bdd 100644 --- a/app/models/plaid_account/importer.rb +++ b/app/models/plaid_account/importer.rb @@ -23,7 +23,7 @@ class PlaidAccount::Importer end def import_investments - plaid_account.upsert_plaid_investments_snapshot!(account_snapshot.investments_data) + plaid_account.upsert_plaid_holdings_snapshot!(account_snapshot.investments_data) end def import_liabilities diff --git a/app/models/plaid_account/investments/balance_calculator.rb b/app/models/plaid_account/investments/balance_calculator.rb index eade15ee4..852f29a0d 100644 --- a/app/models/plaid_account/investments/balance_calculator.rb +++ b/app/models/plaid_account/investments/balance_calculator.rb @@ -44,7 +44,7 @@ class PlaidAccount::Investments::BalanceCalculator attr_reader :plaid_account, :security_resolver def holdings - plaid_account.raw_investments_payload["holdings"] || [] + plaid_account.raw_holdings_payload&.dig("holdings") || [] end def calculate_investment_brokerage_cash diff --git a/app/models/plaid_account/investments/holdings_processor.rb b/app/models/plaid_account/investments/holdings_processor.rb index 493b8a9d1..44b967ea2 100644 --- a/app/models/plaid_account/investments/holdings_processor.rb +++ b/app/models/plaid_account/investments/holdings_processor.rb @@ -51,7 +51,7 @@ class PlaidAccount::Investments::HoldingsProcessor end def holdings - plaid_account.raw_investments_payload&.[]("holdings") || [] + plaid_account.raw_holdings_payload&.[]("holdings") || [] end def parse_decimal(value) diff --git a/app/models/plaid_account/investments/security_resolver.rb b/app/models/plaid_account/investments/security_resolver.rb index a8f15b0ea..5d4fa1d98 100644 --- a/app/models/plaid_account/investments/security_resolver.rb +++ b/app/models/plaid_account/investments/security_resolver.rb @@ -43,7 +43,7 @@ class PlaidAccount::Investments::SecurityResolver Response = Struct.new(:security, :cash_equivalent?, :brokerage_cash?, keyword_init: true) def securities - plaid_account.raw_investments_payload["securities"] || [] + plaid_account.raw_holdings_payload&.dig("securities") || [] end # Tries to find security, or returns the "proxy security" (common with options contracts that have underlying securities) diff --git a/app/models/plaid_account/investments/transactions_processor.rb b/app/models/plaid_account/investments/transactions_processor.rb index 5cabccb2c..efce85118 100644 --- a/app/models/plaid_account/investments/transactions_processor.rb +++ b/app/models/plaid_account/investments/transactions_processor.rb @@ -98,7 +98,7 @@ class PlaidAccount::Investments::TransactionsProcessor end def transactions - plaid_account.raw_investments_payload["transactions"] || [] + plaid_account.raw_holdings_payload&.dig("transactions") || [] end # Plaid unfortunately returns incorrect signage on some `quantity` values. They claim all "sell" transactions diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index c60c8421c..c7f325134 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -1,21 +1,14 @@ class PlaidItem < ApplicationRecord - include Syncable, Provided + include Syncable, Provided, Encryptable enum :plaid_region, { us: "us", eu: "eu" } enum :status, { good: "good", requires_update: "requires_update" }, default: :good - # Helper to detect if ActiveRecord Encryption is configured for this app - def self.encryption_ready? - creds_ready = Rails.application.credentials.active_record_encryption.present? - env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? && - ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? && - ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present? - creds_ready || env_ready - end - - # Encrypt sensitive credentials if ActiveRecord encryption is configured (credentials OR env vars) + # Encrypt sensitive credentials and raw payloads if ActiveRecord encryption is configured if encryption_ready? encrypts :access_token, deterministic: true + encrypts :raw_payload + encrypts :raw_institution_payload end validates :name, presence: true diff --git a/app/models/plaid_item/syncer.rb b/app/models/plaid_item/syncer.rb index 58c2baccd..74d66a58b 100644 --- a/app/models/plaid_item/syncer.rb +++ b/app/models/plaid_item/syncer.rb @@ -61,7 +61,7 @@ class PlaidItem::Syncer def count_holdings(plaid_accounts) plaid_accounts.sum do |pa| - Array(pa.raw_investments_payload).size + pa.raw_holdings_payload&.dig("holdings")&.size || 0 end end end diff --git a/app/models/provider/stripe/subscription_event_processor.rb b/app/models/provider/stripe/subscription_event_processor.rb index 360a7f74a..891b87aea 100644 --- a/app/models/provider/stripe/subscription_event_processor.rb +++ b/app/models/provider/stripe/subscription_event_processor.rb @@ -10,7 +10,8 @@ class Provider::Stripe::SubscriptionEventProcessor < Provider::Stripe::EventProc interval: subscription_details.plan.interval, amount: subscription_details.plan.amount / 100.0, # Stripe returns cents, we report dollars currency: subscription_details.plan.currency.upcase, - current_period_ends_at: Time.at(subscription_details.current_period_end) + current_period_ends_at: Time.at(subscription_details.current_period_end), + cancel_at_period_end: subscription.cancel_at_period_end ) end diff --git a/app/models/rule/condition_filter/transaction_type.rb b/app/models/rule/condition_filter/transaction_type.rb new file mode 100644 index 000000000..4c852d236 --- /dev/null +++ b/app/models/rule/condition_filter/transaction_type.rb @@ -0,0 +1,42 @@ +class Rule::ConditionFilter::TransactionType < Rule::ConditionFilter + # Transfer kinds matching Transaction#transfer? method + TRANSFER_KINDS = %w[funds_movement cc_payment loan_payment].freeze + + def type + "select" + end + + def options + [ + [ I18n.t("rules.condition_filters.transaction_type.income"), "income" ], + [ I18n.t("rules.condition_filters.transaction_type.expense"), "expense" ], + [ I18n.t("rules.condition_filters.transaction_type.transfer"), "transfer" ] + ] + end + + def operators + [ [ I18n.t("rules.condition_filters.transaction_type.equal_to"), "=" ] ] + end + + def prepare(scope) + scope.with_entry + end + + def apply(scope, operator, value) + # Logic matches Transaction::Search#apply_type_filter for consistency + case value + when "income" + # Negative amounts, excluding transfers and investment_contribution + scope.where("entries.amount < 0") + .where.not(kind: TRANSFER_KINDS + %w[investment_contribution]) + when "expense" + # Positive amounts OR investment_contribution (regardless of sign), excluding transfers + scope.where("entries.amount >= 0 OR transactions.kind = 'investment_contribution'") + .where.not(kind: TRANSFER_KINDS) + when "transfer" + scope.where(kind: TRANSFER_KINDS) + else + scope + end + end +end diff --git a/app/models/rule/registry/transaction_resource.rb b/app/models/rule/registry/transaction_resource.rb index fac1d6667..3e0117fc7 100644 --- a/app/models/rule/registry/transaction_resource.rb +++ b/app/models/rule/registry/transaction_resource.rb @@ -7,6 +7,7 @@ class Rule::Registry::TransactionResource < Rule::Registry [ Rule::ConditionFilter::TransactionName.new(rule), Rule::ConditionFilter::TransactionAmount.new(rule), + Rule::ConditionFilter::TransactionType.new(rule), Rule::ConditionFilter::TransactionMerchant.new(rule), Rule::ConditionFilter::TransactionCategory.new(rule), Rule::ConditionFilter::TransactionDetails.new(rule), diff --git a/app/models/session.rb b/app/models/session.rb index 66faa0f59..b84d056c2 100644 --- a/app/models/session.rb +++ b/app/models/session.rb @@ -1,14 +1,18 @@ class Session < ApplicationRecord + include Encryptable + + # Encrypt user_agent if ActiveRecord encryption is configured + if encryption_ready? + encrypts :user_agent + end + belongs_to :user belongs_to :active_impersonator_session, -> { where(status: :in_progress) }, class_name: "ImpersonationSession", optional: true - before_create do - self.user_agent = Current.user_agent - self.ip_address = Current.ip_address - end + before_create :capture_session_info def get_preferred_tab(tab_key) data.dig("tab_preferences", tab_key) @@ -19,4 +23,13 @@ class Session < ApplicationRecord data["tab_preferences"][tab_key] = tab_value save! end + + private + + def capture_session_info + self.user_agent = Current.user_agent + raw_ip = Current.ip_address + self.ip_address = raw_ip + self.ip_address_digest = Digest::SHA256.hexdigest(raw_ip.to_s) if raw_ip.present? + end end diff --git a/app/models/simplefin_account.rb b/app/models/simplefin_account.rb index 2a6592317..8b89db432 100644 --- a/app/models/simplefin_account.rb +++ b/app/models/simplefin_account.rb @@ -1,4 +1,13 @@ class SimplefinAccount < ApplicationRecord + include Encryptable + + # Encrypt raw payloads if ActiveRecord encryption is configured + if encryption_ready? + encrypts :raw_payload + encrypts :raw_transactions_payload + encrypts :raw_holdings_payload + end + belongs_to :simplefin_item # Legacy association via foreign key (will be removed after migration) diff --git a/app/models/simplefin_item.rb b/app/models/simplefin_item.rb index 0e3a761b4..07039ad77 100644 --- a/app/models/simplefin_item.rb +++ b/app/models/simplefin_item.rb @@ -1,5 +1,5 @@ class SimplefinItem < ApplicationRecord - include Syncable, Provided + include Syncable, Provided, Encryptable include SimplefinItem::Unlinking enum :status, { good: "good", requires_update: "requires_update" }, default: :good @@ -7,18 +7,11 @@ class SimplefinItem < ApplicationRecord # Virtual attribute for the setup token form field attr_accessor :setup_token - # Helper to detect if ActiveRecord Encryption is configured for this app - def self.encryption_ready? - creds_ready = Rails.application.credentials.active_record_encryption.present? - env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? && - ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? && - ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present? - creds_ready || env_ready - end - - # Encrypt sensitive credentials if ActiveRecord encryption is configured (credentials OR env vars) + # Encrypt sensitive credentials and raw payloads if ActiveRecord encryption is configured if encryption_ready? encrypts :access_url, deterministic: true + encrypts :raw_payload + encrypts :raw_institution_payload end validates :name, presence: true diff --git a/app/models/sso_provider.rb b/app/models/sso_provider.rb index 21e3dbf33..d41b4c0e8 100644 --- a/app/models/sso_provider.rb +++ b/app/models/sso_provider.rb @@ -1,8 +1,12 @@ # frozen_string_literal: true class SsoProvider < ApplicationRecord - # Encrypt sensitive credentials using Rails 7.2 built-in encryption - encrypts :client_secret, deterministic: false + include Encryptable + + # Encrypt sensitive credentials if ActiveRecord encryption is configured + if encryption_ready? + encrypts :client_secret, deterministic: false + end # Default enabled to true for new providers attribute :enabled, :boolean, default: true diff --git a/app/models/subscription.rb b/app/models/subscription.rb index 0cdad97fb..83dc609f0 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -35,4 +35,8 @@ class Subscription < ApplicationRecord "Open demo" end end + + def pending_cancellation? + active? && cancel_at_period_end? + end end diff --git a/app/models/user.rb b/app/models/user.rb index 98f7d983b..3ab9276f8 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,8 +1,27 @@ class User < ApplicationRecord + include Encryptable + # Allow nil password for SSO-only users (JIT provisioning). # Custom validation ensures password is present for non-SSO registration. has_secure_password validations: false + # Encrypt sensitive fields if ActiveRecord encryption is configured + if encryption_ready? + # MFA secrets + encrypts :otp_secret, deterministic: true + # Note: otp_backup_codes is a PostgreSQL array column which doesn't support + # AR encryption. To encrypt it, a migration would be needed to change the + # column type from array to text/jsonb. + + # PII - emails (deterministic for lookups, downcase for case-insensitive) + encrypts :email, deterministic: true, downcase: true + encrypts :unconfirmed_email, deterministic: true, downcase: true + + # PII - names (non-deterministic for maximum security) + encrypts :first_name + encrypts :last_name + end + belongs_to :family belongs_to :last_viewed_chat, class_name: "Chat", optional: true has_many :sessions, dependent: :destroy @@ -20,6 +39,7 @@ class User < ApplicationRecord validate :ensure_valid_profile_image validates :default_period, inclusion: { in: Period::PERIODS.keys } validates :default_account_order, inclusion: { in: AccountOrder::ORDERS.keys } + validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }, allow_nil: true # Password is required on create unless the user is being created via SSO JIT. # SSO JIT users have password_digest = nil and authenticate via OIDC only. @@ -332,7 +352,7 @@ class User < ApplicationRecord if (index = otp_backup_codes.index(code)) remaining_codes = otp_backup_codes.dup remaining_codes.delete_at(index) - update_column(:otp_backup_codes, remaining_codes) + update!(otp_backup_codes: remaining_codes) true else false diff --git a/app/views/budget_categories/_budget_category.html.erb b/app/views/budget_categories/_budget_category.html.erb index fc5829101..efa6467c1 100644 --- a/app/views/budget_categories/_budget_category.html.erb +++ b/app/views/budget_categories/_budget_category.html.erb @@ -61,14 +61,14 @@ <%= format_money(budget_category.actual_spending_money) %> -
+
<%= t("reports.budget_performance.budgeted") %>: <%= format_money(budget_category.budgeted_spending_money) %> - <% if budget_category.inherits_parent_budget? %> - (<%= t("reports.budget_performance.shared") %>) - <% end %> + <% if budget_category.inherits_parent_budget? %> + <%= t("reports.budget_performance.shared") %> + <% end %>
<% if budget_category.available_to_spend >= 0 %> diff --git a/app/views/coinbase_items/_coinbase_item.html.erb b/app/views/coinbase_items/_coinbase_item.html.erb index eca7c6d6e..4a8d39b7a 100644 --- a/app/views/coinbase_items/_coinbase_item.html.erb +++ b/app/views/coinbase_items/_coinbase_item.html.erb @@ -1,6 +1,21 @@ <%# locals: (coinbase_item:) %> <%= tag.div id: dom_id(coinbase_item) do %> + <%# Compute unlinked count early so it's available for both menu and bottom section %> + <% unlinked_count = if defined?(@coinbase_unlinked_count_map) && @coinbase_unlinked_count_map + @coinbase_unlinked_count_map[coinbase_item.id] || 0 + else + begin + coinbase_item.coinbase_accounts + .left_joins(:account_provider) + .where(account_providers: { id: nil }) + .count + rescue => e + Rails.logger.warn("Coinbase card: unlinked_count fallback failed: #{e.class} - #{e.message}") + 0 + end + end %> +
@@ -55,15 +70,25 @@ href: settings_providers_path, frame: "_top" ) %> - <% elsif Rails.env.development? %> + <% else %> <%= icon( "refresh-cw", as_button: true, - href: sync_coinbase_item_path(coinbase_item) + href: sync_coinbase_item_path(coinbase_item), + disabled: coinbase_item.syncing? ) %> <% end %> <%= render DS::Menu.new do |menu| %> + <% if unlinked_count.to_i > 0 %> + <% menu.with_item( + variant: "link", + text: t(".import_wallets_menu"), + icon: "plus", + href: setup_accounts_coinbase_item_path(coinbase_item), + frame: :modal + ) %> + <% end %> <% menu.with_item( variant: "button", text: t(".delete"), @@ -93,20 +118,6 @@ provider_item: coinbase_item ) %> - <%# Compute unlinked Coinbase accounts (no AccountProvider link) %> - <% unlinked_count = if defined?(@coinbase_unlinked_count_map) && @coinbase_unlinked_count_map - @coinbase_unlinked_count_map[coinbase_item.id] || 0 - else - begin - coinbase_item.coinbase_accounts - .left_joins(:account_provider) - .where(account_providers: { id: nil }) - .count - rescue => e - 0 - end - end %> - <% if unlinked_count.to_i > 0 && coinbase_item.accounts.empty? %> <%# No accounts imported yet - show prominent setup prompt %>
@@ -120,16 +131,6 @@ frame: :modal ) %>
- <% elsif unlinked_count.to_i > 0 %> - <%# Some accounts imported, more available - show subtle link %> -
- <%= link_to setup_accounts_coinbase_item_path(coinbase_item), - data: { turbo_frame: :modal }, - class: "flex items-center gap-2 text-sm text-secondary hover:text-primary transition-colors" do %> - <%= icon "plus", size: "sm" %> - <%= t(".more_wallets_available", count: unlinked_count) %> - <% end %> -
<% elsif coinbase_item.accounts.empty? && coinbase_item.coinbase_accounts.none? %> <%# No coinbase_accounts at all - waiting for sync %>
diff --git a/app/views/entries/_protection_indicator.html.erb b/app/views/entries/_protection_indicator.html.erb new file mode 100644 index 000000000..922408c2b --- /dev/null +++ b/app/views/entries/_protection_indicator.html.erb @@ -0,0 +1,42 @@ +<%# locals: (entry:, unlock_path:) %> + +<%# Protection indicator - shows when entry is protected from sync overwrites %> +<%= turbo_frame_tag dom_id(entry, :protection) do %> + <% if entry.protected_from_sync? && !entry.excluded? %> +
+ + <%= icon "lock", size: "sm", class: "text-secondary" %> + <%= t("entries.protection.title") %> + <%= icon "chevron-down", size: "sm", class: "text-secondary transition-transform [[open]>&]:rotate-180" %> + +
+

+ <%= t("entries.protection.description") %> +

+ + <% if entry.locked_field_names.any? %> +
+

<%= t("entries.protection.locked_fields_label") %>

+ <% entry.locked_fields_with_timestamps.each do |field, timestamp| %> +
+ <%= field.humanize %> + <%= timestamp.respond_to?(:strftime) ? l(timestamp.to_date, format: :long) : timestamp %> +
+ <% end %> +
+ <% end %> + + <%= link_to unlock_path, + class: "w-full flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium rounded-lg border border-secondary text-primary hover:bg-surface-hover transition-colors", + data: { + turbo_method: :post, + turbo_confirm: t("entries.protection.unlock_confirm"), + turbo_frame: "_top" + } do %> + <%= icon "unlock", size: "sm" %> + <%= t("entries.protection.unlock_button") %> + <% end %> +
+
+ <% end %> +<% end %> diff --git a/app/views/import/configurations/_transaction_import.html.erb b/app/views/import/configurations/_transaction_import.html.erb index 5f6a3c305..e0466d550 100644 --- a/app/views/import/configurations/_transaction_import.html.erb +++ b/app/views/import/configurations/_transaction_import.html.erb @@ -82,11 +82,21 @@
" data-import-target="amountTypeValue"> Set - <%= form.select :amount_type_inflow_value, + <%= form.select :amount_type_identifier_value, @import.selectable_amount_type_values, - { prompt: "Select column", container_class: "w-48 px-3 py-1.5 border border-secondary rounded-md" }, + { prompt: "Select value", container_class: "w-48 px-3 py-1.5 border border-secondary rounded-md" }, + required: @import.amount_type_strategy == "custom_column", + data: { action: "import#handleAmountTypeIdentifierChange" } %> + as identifier value +
+ +
" data-import-target="amountTypeInflowValue"> + + Treat "<%= @import.amount_type_identifier_value %>" as + <%= form.select :amount_type_inflow_value, + [["Income (inflow)", "inflows_positive"], ["Expense (outflow)", "inflows_negative"]], + { prompt: "Select type", container_class: "w-48 px-3 py-1.5 border border-secondary rounded-md" }, required: @import.amount_type_strategy == "custom_column" %> - as "income" (inflow) value
<% end %> diff --git a/app/views/layouts/auth.html.erb b/app/views/layouts/auth.html.erb index f12f5b2a0..557128a04 100644 --- a/app/views/layouts/auth.html.erb +++ b/app/views/layouts/auth.html.erb @@ -1,6 +1,6 @@ <%= render "layouts/shared/htmldoc" do %> -
-
+
+
diff --git a/app/views/onboardings/preferences.html.erb b/app/views/onboardings/preferences.html.erb index a017f64d3..f163f5b61 100644 --- a/app/views/onboardings/preferences.html.erb +++ b/app/views/onboardings/preferences.html.erb @@ -76,7 +76,7 @@ <%= family_form.select :locale, language_options, - { label: t(".locale"), required: true, selected: params[:locale] || @user.family.locale }, + { label: t(".locale"), required: true, selected: params[:locale] || @user.locale || I18n.locale }, { data: { action: "onboarding#setLocale" } } %> <%= family_form.select :currency, diff --git a/app/views/properties/_form.html.erb b/app/views/properties/_form.html.erb index ea7b7ae04..7dc2b8b58 100644 --- a/app/views/properties/_form.html.erb +++ b/app/views/properties/_form.html.erb @@ -13,7 +13,7 @@ <%= property_form.number_field :year_built, label: t("properties.form.year_built"), placeholder: t("properties.form.year_built_placeholder"), - min: 1800, + min: 1500, max: Time.current.year %>
diff --git a/app/views/properties/_overview_fields.html.erb b/app/views/properties/_overview_fields.html.erb index cd263ae90..7f7201d7d 100644 --- a/app/views/properties/_overview_fields.html.erb +++ b/app/views/properties/_overview_fields.html.erb @@ -17,7 +17,7 @@ <%= property_form.number_field :year_built, label: "Year Built (optional)", placeholder: "1990", - min: 1800, + min: 1500, max: Time.current.year %>
diff --git a/app/views/settings/_section.html.erb b/app/views/settings/_section.html.erb index 0c9a4f586..bb5873839 100644 --- a/app/views/settings/_section.html.erb +++ b/app/views/settings/_section.html.erb @@ -1,6 +1,8 @@ -<%# locals: (title:, subtitle: nil, content:, collapsible: false, open: true) %> +<%# locals: (title:, subtitle: nil, content:, collapsible: false, open: true, auto_open_param: nil) %> <% if collapsible %> -
class="group bg-container shadow-border-xs rounded-xl p-4"> +
+ class="group bg-container shadow-border-xs rounded-xl p-4" + <%= "data-controller=\"auto-open\" data-auto-open-param-value=\"#{h(auto_open_param)}\"".html_safe if auto_open_param.present? %>>
<%= icon "chevron-right", class: "text-secondary group-open:transform group-open:rotate-90 transition-transform" %> diff --git a/app/views/settings/payments/show.html.erb b/app/views/settings/payments/show.html.erb index ca56148ec..b395545a3 100644 --- a/app/views/settings/payments/show.html.erb +++ b/app/views/settings/payments/show.html.erb @@ -16,7 +16,11 @@ Currently on the <%= @family.subscription.name %>.
<% if @family.next_payment_date %> - <%= t("views.settings.payments.renewal", date: l(@family.next_payment_date, format: :long)) %> + <% if @family.subscription_pending_cancellation? %> + <%= t("views.settings.payments.cancellation", date: l(@family.next_payment_date, format: :long)) %> + <% else %> + <%= t("views.settings.payments.renewal", date: l(@family.next_payment_date, format: :long)) %> + <% end %> <% end %>

<% elsif @family.trialing? %> diff --git a/app/views/settings/preferences/show.html.erb b/app/views/settings/preferences/show.html.erb index 8911e8178..769b31801 100644 --- a/app/views/settings/preferences/show.html.erb +++ b/app/views/settings/preferences/show.html.erb @@ -5,16 +5,16 @@ <%= styled_form_with model: @user, class: "space-y-4", data: { controller: "auto-submit-form" } do |form| %> <%= form.hidden_field :redirect_to, value: "preferences" %> + <%= form.select :locale, + language_options, + { label: t(".language"), include_blank: t(".language_auto") }, + { data: { auto_submit_form_target: "auto" } } %> + <%= form.fields_for :family do |family_form| %> <%= family_form.select :currency, Money::Currency.as_options.map { |currency| [ "#{currency.name} (#{currency.iso_code})", currency.iso_code ] }, { label: t(".currency") }, disabled: true %> - <%= family_form.select :locale, - language_options, - { label: t(".language") }, - { data: { auto_submit_form_target: "auto" } } %> - <%= family_form.select :timezone, timezone_options, { label: t(".timezone") }, diff --git a/app/views/settings/providers/_snaptrade_panel.html.erb b/app/views/settings/providers/_snaptrade_panel.html.erb index 572cd5d28..314213acc 100644 --- a/app/views/settings/providers/_snaptrade_panel.html.erb +++ b/app/views/settings/providers/_snaptrade_panel.html.erb @@ -54,31 +54,26 @@
<% if items&.any? %> <% item = items.first %> -
-
- <% if item.user_registered? %> -
-

- <%= t("providers.snaptrade.status_connected", count: item.snaptrade_accounts.count) %> - <% if item.unlinked_accounts_count > 0 %> - (<%= t("providers.snaptrade.needs_setup", count: item.unlinked_accounts_count) %>) - <% end %> -

- <% else %> -
-

<%= t("providers.snaptrade.status_needs_registration") %>

- <% end %> -
-
- <% if item.user_registered? %> -
- - <%= t("providers.snaptrade.manage_connections") %> - <%= icon "chevron-right", class: "w-3 h-3 transition-transform group-open:rotate-90" %> + data-lazy-load-url-value="<%= connections_snaptrade_item_path(item) %>" + data-lazy-load-auto-open-param-value="manage"> + +
+
+

+ <%= t("providers.snaptrade.status_connected", count: item.snaptrade_accounts.count) %> + <% if item.unlinked_accounts_count > 0 %> + (<%= t("providers.snaptrade.needs_setup", count: item.unlinked_accounts_count) %>) + <% end %> +

+
+ + <%= t("providers.snaptrade.manage_connections") %> + <%= icon "chevron-right", class: "w-3 h-3 transition-transform group-open:rotate-90" %> +
@@ -86,7 +81,6 @@ <%= t("providers.snaptrade.connection_limit_info") %>

- <%# Loading state - replaced by fetched content %>
<%= icon "loader-2", class: "w-4 h-4 animate-spin" %> <%= t("providers.snaptrade.loading_connections") %> @@ -96,6 +90,11 @@
+ <% else %> +
+
+

<%= t("providers.snaptrade.status_needs_registration") %>

+
<% end %> <% else %>
diff --git a/app/views/settings/providers/show.html.erb b/app/views/settings/providers/show.html.erb index 1b6f433c4..c7e922f68 100644 --- a/app/views/settings/providers/show.html.erb +++ b/app/views/settings/providers/show.html.erb @@ -1,62 +1,76 @@ <%= content_for :page_title, "Sync Providers" %>
-
-

- Configure credentials for third-party sync providers. Settings configured here will override environment variables. -

-
+ <% if @encryption_error %> +
+
+ <%= icon("triangle-alert", class: "w-5 h-5 text-destructive shrink-0 mt-0.5") %> +
+

<%= t("settings.providers.encryption_error.title") %>

+

<%= t("settings.providers.encryption_error.message") %>

+
+
+
+ <% else %> +
+

+ Configure credentials for third-party sync providers. Settings configured here will override environment variables. +

+
+ <% end %> - <% @provider_configurations.each do |config| %> - <%= settings_section title: config.provider_key.titleize, collapsible: true, open: false do %> - <%= render "settings/providers/provider_form", configuration: config %> + <% unless @encryption_error %> + <% @provider_configurations.each do |config| %> + <%= settings_section title: config.provider_key.titleize, collapsible: true, open: false do %> + <%= render "settings/providers/provider_form", configuration: config %> + <% end %> + <% end %> + + <%# Providers below are hardcoded because they manage Family-scoped connections %> + <%# (via their own models like SimplefinItem, LunchflowItem, etc.) rather than global settings. %> + <%# They require custom UI for connection management, status display, and sync actions. %> + <%# The controller excludes them from @provider_configurations (see prepare_show_context). %> + + <%= settings_section title: "Lunch Flow", collapsible: true, open: false do %> + + <%= render "settings/providers/lunchflow_panel" %> + + <% end %> + + <%= settings_section title: "SimpleFIN", collapsible: true, open: false do %> + + <%= render "settings/providers/simplefin_panel" %> + + <% end %> + + <%= settings_section title: "Enable Banking (beta)", collapsible: true, open: false do %> + + <%= render "settings/providers/enable_banking_panel" %> + + <% end %> + + <%= settings_section title: "CoinStats (beta)", collapsible: true, open: false do %> + + <%= render "settings/providers/coinstats_panel" %> + + <% end %> + + <%= settings_section title: "Mercury (beta)", collapsible: true, open: false do %> + + <%= render "settings/providers/mercury_panel" %> + + <% end %> + + <%= settings_section title: "Coinbase (beta)", collapsible: true, open: false do %> + + <%= render "settings/providers/coinbase_panel" %> + + <% end %> + + <%= settings_section title: "SnapTrade (beta)", collapsible: true, open: false, auto_open_param: "manage" do %> + + <%= render "settings/providers/snaptrade_panel" %> + <% end %> <% end %> - - <%# Providers below are hardcoded because they manage Family-scoped connections %> - <%# (via their own models like SimplefinItem, LunchflowItem, etc.) rather than global settings. %> - <%# They require custom UI for connection management, status display, and sync actions. %> - <%# The controller excludes them from @provider_configurations (see prepare_show_context). %> - - <%= settings_section title: "Lunch Flow", collapsible: true, open: false do %> - - <%= render "settings/providers/lunchflow_panel" %> - - <% end %> - - <%= settings_section title: "SimpleFIN", collapsible: true, open: false do %> - - <%= render "settings/providers/simplefin_panel" %> - - <% end %> - - <%= settings_section title: "Enable Banking (beta)", collapsible: true, open: false do %> - - <%= render "settings/providers/enable_banking_panel" %> - - <% end %> - - <%= settings_section title: "CoinStats (beta)", collapsible: true, open: false do %> - - <%= render "settings/providers/coinstats_panel" %> - - <% end %> - - <%= settings_section title: "Mercury (beta)", collapsible: true, open: false do %> - - <%= render "settings/providers/mercury_panel" %> - - <% end %> - - <%= settings_section title: "Coinbase (beta)", collapsible: true, open: false do %> - - <%= render "settings/providers/coinbase_panel" %> - - <% end %> - - <%= settings_section title: "SnapTrade (beta)", collapsible: true, open: false do %> - - <%= render "settings/providers/snaptrade_panel" %> - - <% end %>
diff --git a/app/views/simplefin_items/_simplefin_item.html.erb b/app/views/simplefin_items/_simplefin_item.html.erb index 530841a2b..cf6c8da35 100644 --- a/app/views/simplefin_items/_simplefin_item.html.erb +++ b/app/views/simplefin_items/_simplefin_item.html.erb @@ -1,6 +1,21 @@ <%# locals: (simplefin_item:) %> <%= tag.div id: dom_id(simplefin_item) do %> + <%# Compute unlinked count early so it's available for both menu and bottom section %> + <% unlinked_count = if defined?(@simplefin_unlinked_count_map) && @simplefin_unlinked_count_map + @simplefin_unlinked_count_map[simplefin_item.id] || 0 + else + begin + simplefin_item.simplefin_accounts + .left_joins(:account, :account_provider) + .where(accounts: { id: nil }, account_providers: { id: nil }) + .count + rescue => e + Rails.logger.warn("SimpleFin card: unlinked_count fallback failed: #{e.class} - #{e.message}") + 0 + end + end %> +
@@ -16,31 +31,9 @@ <% end %>
- <%# Compute unlinked count early for badge display %> - <% header_unlinked_count = if defined?(@simplefin_unlinked_count_map) && @simplefin_unlinked_count_map - @simplefin_unlinked_count_map[simplefin_item.id] || 0 - else - begin - simplefin_item.simplefin_accounts - .left_joins(:account, :account_provider) - .where(accounts: { id: nil }, account_providers: { id: nil }) - .count - rescue => e - 0 - end - end %> -
<%= tag.p simplefin_item.name, class: "font-medium text-primary" %> - <% if header_unlinked_count.to_i > 0 %> - <%= link_to setup_accounts_simplefin_item_path(simplefin_item), - data: { turbo_frame: :modal }, - class: "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-warning/10 text-warning hover:bg-warning/20 transition-colors" do %> - <%= icon "alert-circle", size: "xs" %> - <%= header_unlinked_count %> <%= header_unlinked_count == 1 ? "account" : "accounts" %> need setup - <% end %> - <% end %> <% if simplefin_item.scheduled_for_deletion? %>

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

<% end %> @@ -49,25 +42,25 @@

<%= simplefin_item.institution_summary %>

- <%# Extra inline badges from latest sync stats %> + <%# Extra inline badges from latest sync stats - only show warnings %> <% stats = (@simplefin_sync_stats_map || {})[simplefin_item.id] || {} %> - <% if stats.present? %> + <% has_warnings = stats["accounts_skipped"].to_i > 0 || + stats["rate_limited"].present? || + stats["rate_limited_at"].present? || + stats["total_errors"].to_i > 0 || + (stats["errors"].is_a?(Array) && stats["errors"].any?) %> + <% if has_warnings %>
- <% if stats["unlinked_accounts"].to_i > 0 %> - <%= render DS::Tooltip.new(text: "Accounts need setup", icon: "link-2", size: "sm") %> - Unlinked: <%= stats["unlinked_accounts"].to_i %> - <% end %> - <% if stats["accounts_skipped"].to_i > 0 %> - <%= render DS::Tooltip.new(text: "Some accounts were skipped due to errors during sync", icon: "alert-triangle", size: "sm", color: "warning") %> - Skipped: <%= stats["accounts_skipped"].to_i %> + <%= render DS::Tooltip.new(text: t(".accounts_skipped_tooltip"), icon: "alert-triangle", size: "sm", color: "warning") %> + <%= t(".accounts_skipped_label", count: stats["accounts_skipped"].to_i) %> <% end %> <% if stats["rate_limited"].present? || stats["rate_limited_at"].present? %> <% ts = stats["rate_limited_at"] %> <% ago = (ts.present? ? (begin; time_ago_in_words(Time.parse(ts)); rescue StandardError; nil; end) : nil) %> <%= render DS::Tooltip.new( - text: (ago ? "Rate limited (" + ago + " ago)" : "Rate limited recently"), + text: (ago ? t(".rate_limited_ago", time: ago) : t(".rate_limited_recently")), icon: "clock", size: "sm", color: "warning" @@ -80,10 +73,6 @@ <%= render DS::Tooltip.new(text: tooltip_text, icon: "alert-octagon", size: "sm", color: "warning") %> <% end %> <% end %> - - <% if stats["total_accounts"].to_i > 0 %> - Total: <%= stats["total_accounts"].to_i %> - <% end %>
<% end %> <% end %> @@ -163,15 +152,25 @@ href: edit_simplefin_item_path(simplefin_item), frame: "modal" ) %> - <% elsif Rails.env.development? %> + <% else %> <%= icon( "refresh-cw", as_button: true, - href: sync_simplefin_item_path(simplefin_item) + href: sync_simplefin_item_path(simplefin_item), + disabled: simplefin_item.syncing? ) %> <% end %> <%= render DS::Menu.new do |menu| %> + <% if unlinked_count.to_i > 0 %> + <% menu.with_item( + variant: "link", + text: t(".setup_accounts_menu"), + icon: "settings", + href: setup_accounts_simplefin_item_path(simplefin_item), + frame: :modal + ) %> + <% end %> <% menu.with_item( variant: "button", text: t(".delete"), @@ -205,23 +204,8 @@ institutions_count: simplefin_item.connected_institutions.size ) %> - <%# Compute unlinked SimpleFin accounts (no legacy account and no AccountProvider link) - # Prefer controller-provided map; fallback to a local query so the card stays accurate after Turbo broadcasts %> - <% unlinked_count = if defined?(@simplefin_unlinked_count_map) && @simplefin_unlinked_count_map - @simplefin_unlinked_count_map[simplefin_item.id] || 0 - else - begin - simplefin_item.simplefin_accounts - .left_joins(:account, :account_provider) - .where(accounts: { id: nil }, account_providers: { id: nil }) - .count - rescue => e - Rails.logger.warn("SimpleFin card: unlinked_count fallback failed: #{e.class} - #{e.message}") - 0 - end - end %> - - <% if unlinked_count.to_i > 0 %> + <% if unlinked_count.to_i > 0 && simplefin_item.accounts.empty? %> + <%# No accounts imported yet - show prominent setup prompt %>

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

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

diff --git a/app/views/snaptrade_items/_snaptrade_item.html.erb b/app/views/snaptrade_items/_snaptrade_item.html.erb index ffa141edf..cc4e27ea1 100644 --- a/app/views/snaptrade_items/_snaptrade_item.html.erb +++ b/app/views/snaptrade_items/_snaptrade_item.html.erb @@ -6,12 +6,12 @@
<%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> -
+
<% if snaptrade_item.logo.attached? %> <%= image_tag snaptrade_item.logo, class: "rounded-full h-full w-full", loading: "lazy" %> <% else %>
- <%= tag.p snaptrade_item.name.first.upcase, class: "text-primary text-xs font-medium" %> + <%= tag.p snaptrade_item.name.first.upcase, class: "text-success text-xs font-medium" %>
<% end %>
@@ -21,14 +21,6 @@
<%= tag.p snaptrade_item.name, class: "font-medium text-primary" %> - <% if unlinked_count > 0 %> - <%= link_to setup_accounts_snaptrade_item_path(snaptrade_item), - data: { turbo_frame: :modal }, - class: "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-warning/10 text-warning hover:bg-warning/20 transition-colors" do %> - <%= icon "alert-circle", size: "xs" %> - <%= t(".accounts_need_setup", count: unlinked_count) %> - <% end %> - <% end %> <% if snaptrade_item.scheduled_for_deletion? %>

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

<% end %> @@ -89,6 +81,21 @@ icon: "plus", href: connect_snaptrade_item_path(snaptrade_item) ) %> + <% if unlinked_count > 0 %> + <% menu.with_item( + variant: "link", + text: t(".setup_accounts_menu"), + icon: "settings", + href: setup_accounts_snaptrade_item_path(snaptrade_item), + frame: :modal + ) %> + <% end %> + <% menu.with_item( + variant: "link", + text: t(".manage_connections"), + icon: "cable", + href: settings_providers_path(manage: "1") + ) %> <% menu.with_item( variant: "button", text: t(".delete"), @@ -105,13 +112,6 @@
<% if snaptrade_item.accounts.any? %> <%= render "accounts/index/account_groups", accounts: snaptrade_item.accounts %> -
- <%= link_to connect_snaptrade_item_path(snaptrade_item), - class: "text-sm text-secondary hover:text-primary flex items-center gap-1 transition-colors" do %> - <%= icon "plus", size: "sm" %> - <%= t(".add_another_brokerage") %> - <% end %> -
<% end %> <%# Sync summary (collapsible) - using shared ProviderSyncSummary component %> @@ -124,7 +124,8 @@ activities_pending: activities_pending ) %> - <% if unlinked_count > 0 %> + <% if unlinked_count > 0 && snaptrade_item.accounts.empty? %> + <%# No accounts imported yet - show prominent setup prompt %>

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

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

diff --git a/app/views/trades/_trade.html.erb b/app/views/trades/_trade.html.erb index 40ec53d05..b14a7527c 100644 --- a/app/views/trades/_trade.html.erb +++ b/app/views/trades/_trade.html.erb @@ -4,7 +4,7 @@ <%= turbo_frame_tag dom_id(entry) do %> <%= turbo_frame_tag dom_id(trade) do %> -
text-sm font-medium p-4"> +
text-sm font-medium p-4">
<%= check_box_tag dom_id(entry, "selection"), class: "checkbox checkbox--light hidden lg:block", @@ -44,7 +44,16 @@ <%= render "investment_activity/quick_edit_badge", entry: entry, entryable: trade %>
-
+
+ <%# Protection indicator - shows on hover when entry is protected from sync %> + <% if entry.protected_from_sync? && !entry.excluded? %> + <%= link_to entry_path(entry), + data: { turbo_frame: "drawer", turbo_prefetch: false }, + class: "invisible group-hover:visible transition-opacity", + title: t("entries.protection.tooltip") do %> + <%= icon "lock", size: "sm", class: "text-secondary" %> + <% end %> + <% end %> <%= content_tag :p, format_money(-entry.amount_money), class: ["text-green-600": entry.amount.negative?] %> diff --git a/app/views/trades/show.html.erb b/app/views/trades/show.html.erb index 6bc7f885a..c60543441 100644 --- a/app/views/trades/show.html.erb +++ b/app/views/trades/show.html.erb @@ -6,6 +6,8 @@ <% trade = @entry.trade %> <% dialog.with_body do %> + <%= render "entries/protection_indicator", entry: @entry, unlock_path: unlock_trade_path(trade) %> + <% dialog.with_section(title: t(".details"), open: true) do %>
<%= styled_form_with model: @entry, diff --git a/app/views/transactions/_transaction.html.erb b/app/views/transactions/_transaction.html.erb index c67c0b9b4..d7993c669 100644 --- a/app/views/transactions/_transaction.html.erb +++ b/app/views/transactions/_transaction.html.erb @@ -4,7 +4,7 @@ <%= turbo_frame_tag dom_id(entry) do %> <%= turbo_frame_tag dom_id(transaction) do %> -
"> +
">
<%= check_box_tag dom_id(entry, "selection"), @@ -22,7 +22,7 @@