+<% end %>
diff --git a/config/locales/views/holdings/de.yml b/config/locales/views/holdings/de.yml
index 0d5bb4219..7fdd2ca74 100644
--- a/config/locales/views/holdings/de.yml
+++ b/config/locales/views/holdings/de.yml
@@ -32,4 +32,7 @@ de:
ticker_label: Ticker
trade_history_entry: "%{qty} Anteile von %{security} zu %{price}"
total_return_label: Gesamtrendite
+ shares_label: Anteile
+ book_value_label: Buchwert
+ market_value_label: Marktwert
unknown: Unbekannt
diff --git a/config/locales/views/holdings/en.yml b/config/locales/views/holdings/en.yml
index a14efe9b6..4fe40c9e6 100644
--- a/config/locales/views/holdings/en.yml
+++ b/config/locales/views/holdings/en.yml
@@ -15,6 +15,10 @@ en:
security_not_found: Could not find the selected security.
reset_security:
success: Security reset to provider value.
+ sync_prices:
+ success: Market data synced successfully.
+ unavailable: Market data sync is not available for offline securities.
+ provider_error: Could not fetch latest prices. Please try again in a few minutes.
errors:
security_collision: "Cannot remap: you already have a holding for %{ticker} on %{date}."
cost_basis_sources:
@@ -82,3 +86,11 @@ en:
unlock_cost_basis: Unlock
unlock_confirm_title: Unlock cost basis?
unlock_confirm_body: This will allow the cost basis to be updated by provider syncs or trade calculations.
+ shares_label: Shares
+ book_value_label: Book Value
+ market_value_label: Market Value
+ market_data_label: Market data
+ market_data_sync_button: Refresh
+ last_price_update: Last price update
+ syncing: Syncing...
+ never: Never
diff --git a/config/locales/views/holdings/es.yml b/config/locales/views/holdings/es.yml
index bc17c9aab..9ebbce072 100644
--- a/config/locales/views/holdings/es.yml
+++ b/config/locales/views/holdings/es.yml
@@ -47,6 +47,10 @@ es:
missing_price_tooltip:
description: Esta inversión tiene valores faltantes y no pudimos calcular su rendimiento o valor.
missing_data: Datos faltantes
+ sync_prices:
+ success: Datos de mercado sincronizados correctamente.
+ unavailable: La sincronización de datos de mercado no está disponible para valores fuera de línea.
+ provider_error: No se pudieron obtener los precios más recientes. Inténtalo de nuevo en unos minutos.
show:
avg_cost_label: Costo promedio
current_market_price_label: Precio de mercado actual
@@ -74,6 +78,14 @@ es:
ticker_label: Ticker
trade_history_entry: "%{qty} acciones de %{security} a %{price}"
total_return_label: Rendimiento total
+ shares_label: Acciones
+ book_value_label: Valor en libros
+ market_value_label: Valor de mercado
+ market_data_label: Datos de mercado
+ market_data_sync_button: Actualizar
+ last_price_update: Última actualización de precio
+ syncing: Sincronizando...
+ never: Nunca
unknown: Desconocido
cost_basis_locked_label: La base de costes está bloqueada
cost_basis_locked_description: La base de costes establecida manualmente no cambiará con las sincronizaciones.
diff --git a/config/locales/views/holdings/fr.yml b/config/locales/views/holdings/fr.yml
index 09c96ef83..2959132a1 100644
--- a/config/locales/views/holdings/fr.yml
+++ b/config/locales/views/holdings/fr.yml
@@ -33,4 +33,7 @@ fr:
ticker_label: Ticker
trade_history_entry: "%{qty} actions de %{security} à %{price}"
total_return_label: Rendement total
+ shares_label: Actions
+ book_value_label: Valeur comptable
+ market_value_label: Valeur marchande
unknown: Inconnu
diff --git a/config/routes.rb b/config/routes.rb
index f5e2fb767..514cece15 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -247,6 +247,7 @@ Rails.application.routes.draw do
post :unlock_cost_basis
patch :remap_security
post :reset_security
+ post :sync_prices
end
end
resources :trades, only: %i[show new create update destroy] do
diff --git a/test/controllers/holdings_controller_test.rb b/test/controllers/holdings_controller_test.rb
index a73680d54..dfa52d499 100644
--- a/test/controllers/holdings_controller_test.rb
+++ b/test/controllers/holdings_controller_test.rb
@@ -61,4 +61,61 @@ class HoldingsControllerTest < ActionDispatch::IntegrationTest
assert_equal 50.0, @holding.cost_basis.to_f
assert_equal "manual", @holding.cost_basis_source
end
+
+ test "remap_security brings offline security back online" do
+ # Given: the target security is marked offline (e.g. created by a failed QIF import)
+ msft = securities(:msft)
+ msft.update!(offline: true, failed_fetch_count: 3)
+
+ # When: user explicitly selects it from the provider search and saves
+ patch remap_security_holding_path(@holding), params: { security_id: "MSFT|XNAS" }
+
+ # Then: the security is brought back online and the holding is remapped
+ assert_redirected_to account_path(@holding.account, tab: "holdings")
+ @holding.reload
+ msft.reload
+ assert_equal msft.id, @holding.security_id
+ assert_not msft.offline?
+ assert_equal 0, msft.failed_fetch_count
+ end
+
+ test "sync_prices redirects with alert for offline security" do
+ @holding.security.update!(offline: true)
+
+ post sync_prices_holding_path(@holding)
+
+ assert_redirected_to account_path(@holding.account, tab: "holdings")
+ assert_equal I18n.t("holdings.sync_prices.unavailable"), flash[:alert]
+ end
+
+ test "sync_prices syncs market data and redirects with notice" do
+ Security.any_instance.expects(:import_provider_prices).with(
+ start_date: 31.days.ago.to_date,
+ end_date: Date.current,
+ clear_cache: true
+ ).returns([ 31, nil ])
+ Security.any_instance.stubs(:import_provider_details)
+ materializer = mock("materializer")
+ materializer.expects(:materialize_balances).once
+ Balance::Materializer.expects(:new).with(
+ @holding.account,
+ strategy: :forward,
+ security_ids: [ @holding.security_id ]
+ ).returns(materializer)
+
+ post sync_prices_holding_path(@holding)
+
+ assert_redirected_to account_path(@holding.account, tab: "holdings")
+ assert_equal I18n.t("holdings.sync_prices.success"), flash[:notice]
+ end
+
+ test "sync_prices shows provider error inline when provider returns no prices" do
+ Security.any_instance.stubs(:import_provider_prices).returns([ 0, "Yahoo Finance rate limit exceeded" ])
+ Security.any_instance.stubs(:import_provider_details)
+
+ post sync_prices_holding_path(@holding)
+
+ assert_redirected_to account_path(@holding.account, tab: "holdings")
+ assert_equal "Yahoo Finance rate limit exceeded", flash[:alert]
+ end
end
diff --git a/test/controllers/settings/hostings_controller_test.rb b/test/controllers/settings/hostings_controller_test.rb
index bd02b321a..f211e07bf 100644
--- a/test/controllers/settings/hostings_controller_test.rb
+++ b/test/controllers/settings/hostings_controller_test.rb
@@ -201,16 +201,18 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest
test "disconnect external assistant clears settings and resets type" do
with_self_hosting do
- Setting.external_assistant_url = "https://agent.example.com/v1/chat"
- Setting.external_assistant_token = "token"
- Setting.external_assistant_agent_id = "finance-bot"
- users(:family_admin).family.update!(assistant_type: "external")
+ with_env_overrides("EXTERNAL_ASSISTANT_URL" => nil, "EXTERNAL_ASSISTANT_TOKEN" => nil) do
+ Setting.external_assistant_url = "https://agent.example.com/v1/chat"
+ Setting.external_assistant_token = "token"
+ Setting.external_assistant_agent_id = "finance-bot"
+ users(:family_admin).family.update!(assistant_type: "external")
- delete disconnect_external_assistant_settings_hosting_url
+ delete disconnect_external_assistant_settings_hosting_url
- assert_redirected_to settings_hosting_url
- assert_not Assistant::External.configured?
- assert_equal "builtin", users(:family_admin).family.reload.assistant_type
+ assert_redirected_to settings_hosting_url
+ assert_not Assistant::External.configured?
+ assert_equal "builtin", users(:family_admin).family.reload.assistant_type
+ end
end
ensure
Setting.external_assistant_url = nil