From 8f5454ad29141fd6eb7c522384105f092501cae0 Mon Sep 17 00:00:00 2001 From: dripsmvcp <138900956+dripsmvcp@users.noreply.github.com> Date: Mon, 25 May 2026 18:23:52 +0900 Subject: [PATCH 1/7] fix(settings): preserve OpenAI form input on validation failure (#1862) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(settings): preserve OpenAI form input on validation failure Fixes #1824. The OpenAI settings form auto-submits on blur, so typing the URI base before the model triggers cross-field validation. The rescue re-renders the page with values read from Setting.openai_*, which is still blank because the failed save was rejected — so the user's input disappears and they see 'OpenAI model is required' with no value to fix. Stash the submitted uri_base and model on rescue and prefer them over the saved Setting when rendering, so the user can finish typing the missing field and re-submit. * test(settings): cover openai_model preservation on validation fail (#1862) jjmata asked for symmetric coverage of the model field. Add a test where the user changes the URI base and clears the model in the same submit: the cross-field validation fails and the re-rendered model input must reflect the submitted (cleared) value rather than reverting to the saved model. Complements the existing uri_base preservation test. --- .../settings/hostings_controller.rb | 6 +++ .../hostings/_openai_settings.html.erb | 4 +- .../settings/hostings_controller_test.rb | 49 +++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/app/controllers/settings/hostings_controller.rb b/app/controllers/settings/hostings_controller.rb index e2a577bfc..e6ac154b8 100644 --- a/app/controllers/settings/hostings_controller.rb +++ b/app/controllers/settings/hostings_controller.rb @@ -200,6 +200,12 @@ class Settings::HostingsController < ApplicationController redirect_to settings_hosting_path, notice: t(".success") rescue Setting::ValidationError => error + # Preserve user-submitted OpenAI config so the form re-renders with their + # input intact (issue #1824). The form auto-submits on blur, so a partial + # entry (e.g. URI base before model) hits validation and would otherwise + # be wiped because the view reads from the unchanged Setting.* values. + @openai_uri_base_input = hosting_params[:openai_uri_base] if hosting_params.key?(:openai_uri_base) + @openai_model_input = hosting_params[:openai_model] if hosting_params.key?(:openai_model) flash.now[:alert] = error.message render :show, status: :unprocessable_entity end diff --git a/app/views/settings/hostings/_openai_settings.html.erb b/app/views/settings/hostings/_openai_settings.html.erb index b54dd7671..a1920e38b 100644 --- a/app/views/settings/hostings/_openai_settings.html.erb +++ b/app/views/settings/hostings/_openai_settings.html.erb @@ -30,7 +30,7 @@ <%= form.text_field :openai_uri_base, label: t(".uri_base_label"), placeholder: t(".uri_base_placeholder"), - value: Setting.openai_uri_base, + value: @openai_uri_base_input || Setting.openai_uri_base, autocomplete: "off", autocapitalize: "none", spellcheck: "false", @@ -41,7 +41,7 @@ <%= form.text_field :openai_model, label: t(".model_label"), placeholder: t(".model_placeholder"), - value: Setting.openai_model, + value: @openai_model_input || Setting.openai_model, autocomplete: "off", autocapitalize: "none", spellcheck: "false", diff --git a/test/controllers/settings/hostings_controller_test.rb b/test/controllers/settings/hostings_controller_test.rb index 4ebe20f87..e4ecca9bb 100644 --- a/test/controllers/settings/hostings_controller_test.rb +++ b/test/controllers/settings/hostings_controller_test.rb @@ -95,6 +95,55 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest end end + # Regression: issue #1824. The OpenAI form auto-submits on blur, so entering + # the URI base before the model fires a partial submit that fails validation. + # The re-rendered form must show the user's submitted URI base — not the + # still-blank saved value — so they can finish typing the model. + test "preserves submitted openai uri base in form when validation fails" do + with_self_hosting do + Setting.openai_uri_base = nil + Setting.openai_model = "" + + patch settings_hosting_url, params: { setting: { openai_uri_base: "https://api.example.com/v1" } } + + assert_response :unprocessable_entity + assert_select "input[name=?]", "setting[openai_uri_base]" do |inputs| + assert_equal "https://api.example.com/v1", inputs.first["value"] + end + end + ensure + Setting.openai_uri_base = nil + Setting.openai_model = nil + end + + # PR #1862 review (jjmata): symmetric coverage for the model field. When the + # user changes the URI base and clears the model in the same auto-submit, the + # cross-field validation fails — the re-rendered model input must reflect the + # user's submitted (cleared) value, not silently revert to the saved model. + test "preserves submitted openai model in form when validation fails" do + with_self_hosting do + Setting.openai_uri_base = "https://saved.example.com/v1" + Setting.openai_model = "saved-model" + + patch settings_hosting_url, params: { setting: { + openai_uri_base: "https://new.example.com/v1", + openai_model: "" + } } + + assert_response :unprocessable_entity + assert_select "input[name=?]", "setting[openai_uri_base]" do |inputs| + assert_equal "https://new.example.com/v1", inputs.first["value"] + end + assert_select "input[name=?]", "setting[openai_model]" do |inputs| + assert_not_equal "saved-model", inputs.first["value"].to_s, + "model field must reflect the submitted (cleared) value, not the saved model" + end + end + ensure + Setting.openai_uri_base = nil + Setting.openai_model = nil + end + test "can update openai model alone when self hosting is enabled" do with_self_hosting do patch settings_hosting_url, params: { setting: { openai_model: "gpt-4" } } From d8a12ad6be3f57ca49eeba394a49151500f1e18b Mon Sep 17 00:00:00 2001 From: "Sure Admin (bot)" Date: Mon, 25 May 2026 15:31:00 +0200 Subject: [PATCH 2/7] fix(preview): only redeploy on preview-cf label changes (#1980) --- .github/workflows/preview-deploy.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml index eff9c847a..06b9c61e0 100644 --- a/.github/workflows/preview-deploy.yml +++ b/.github/workflows/preview-deploy.yml @@ -2,7 +2,7 @@ name: Deploy PR Preview on: pull_request: - types: [opened, synchronize, reopened, labeled, unlabeled] + types: [opened, synchronize, reopened, labeled] paths-ignore: - 'charts/**' - 'docs/**' @@ -10,7 +10,9 @@ on: jobs: deploy-preview: - if: contains(github.event.pull_request.labels.*.name, 'preview-cf') + if: | + contains(github.event.pull_request.labels.*.name, 'preview-cf') && + (github.event.action != 'labeled' || github.event.label.name == 'preview-cf') name: Deploy to Cloudflare Containers runs-on: ubuntu-latest timeout-minutes: 15 From e0537a45e1afcc9b9a0d5820a5d5c14df59143ef Mon Sep 17 00:00:00 2001 From: Alessio Cappa <104093777+alessiocappa@users.noreply.github.com> Date: Tue, 26 May 2026 09:56:42 +0200 Subject: [PATCH 3/7] fix: Avoid overlay in provider section on mobile (#1990) * fix: Avoid overlay in provider section on mobile * feat: Reduce gap between divs * fix: keep all the elements inside a dedicated container to avoid accessibility issues with the summary node --- .../providers/_connection_row.html.erb | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/app/views/settings/providers/_connection_row.html.erb b/app/views/settings/providers/_connection_row.html.erb index 21cf30ebd..1102538e5 100644 --- a/app/views/settings/providers/_connection_row.html.erb +++ b/app/views/settings/providers/_connection_row.html.erb @@ -17,18 +17,24 @@ <%= tag.details open: open, class: "group bg-container shadow-border-xs rounded-xl #{border_class}", data: details_data do %> - - <%= icon "chevron-right", size: "sm", class: "!w-3.5 !h-3.5 text-secondary group-open:rotate-90 transition-transform" %> -
-

<%= entry[:title] %>

- <%= render "settings/providers/maturity_badge", label: maturity_lbl %> -
-
- <% if meta.present? %> - <%= meta %> - <% end %> - <%= status_pill %> - <%= sync_action if sync_action %> + +
+ <%= icon "chevron-right", size: "sm", class: "!w-3.5 !h-3.5 text-secondary group-open:rotate-90 transition-transform" %> +
+
+

<%= entry[:title] %>

+ <%= render "settings/providers/maturity_badge", label: maturity_lbl %> +
+
+ <% if meta.present? %> + <%= meta %> + <% end %> + <%= status_pill %> +
+
+
+ <%= sync_action if sync_action %> +
From 946c4d03916c23effd61d92c371c902925bffab0 Mon Sep 17 00:00:00 2001 From: Rene Arredondo <120709323+Rene0422@users.noreply.github.com> Date: Tue, 26 May 2026 11:48:34 -0700 Subject: [PATCH 4/7] fix(i18n): use %{product_name} in api_keys usage_instructions (#1505) (#2000) --- config/locales/views/settings/api_keys/es.yml | 2 +- config/locales/views/settings/api_keys/nb.yml | 2 +- config/locales/views/settings/api_keys/pl.yml | 2 +- config/locales/views/settings/api_keys/tr.yml | 2 +- config/locales/views/settings/api_keys/zh-TW.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/config/locales/views/settings/api_keys/es.yml b/config/locales/views/settings/api_keys/es.yml index 42f27fc6b..0e9b0c7a6 100644 --- a/config/locales/views/settings/api_keys/es.yml +++ b/config/locales/views/settings/api_keys/es.yml @@ -37,7 +37,7 @@ es: never_expires: "Nunca expira" permissions: "Permisos" usage_instructions_title: "Cómo usar tu clave API" - usage_instructions: "Incluye tu clave API en el encabezado X-Api-Key al realizar solicitudes a la API de Maybe:" + usage_instructions: "Incluye tu clave API en el encabezado X-Api-Key al realizar solicitudes a la API de %{product_name}:" regenerate_key: "Crear Nueva Clave" revoke_key: "Revocar Clave" revoke_confirmation: "¿Estás seguro de que deseas revocar esta clave API? Esta acción no se puede deshacer y deshabilitará inmediatamente todas las aplicaciones que usen esta clave." diff --git a/config/locales/views/settings/api_keys/nb.yml b/config/locales/views/settings/api_keys/nb.yml index c120ac038..482917d4f 100644 --- a/config/locales/views/settings/api_keys/nb.yml +++ b/config/locales/views/settings/api_keys/nb.yml @@ -36,7 +36,7 @@ nb: never_expires: "Utløper aldri" permissions: "Tillatelser" usage_instructions_title: "Hvordan bruke din API-nøkkel" - usage_instructions: "Inkluder din API-nøkkel i X-Api-Key-headeren når du gjør forespørsler til Maybe API-et:" + usage_instructions: "Inkluder din API-nøkkel i X-Api-Key-headeren når du gjør forespørsler til %{product_name} API-et:" regenerate_key: "Opprett ny nøkkel" revoke_key: "Tilbakekall nøkkel" revoke_confirmation: "Er du sikker på at du vil tilbakekalle denne API-nøkkelen? Denne handlingen kan ikke angres og vil umiddelbart deaktivere alle applikasjoner som bruker denne nøkkelen." diff --git a/config/locales/views/settings/api_keys/pl.yml b/config/locales/views/settings/api_keys/pl.yml index bef95529c..e5d95264d 100644 --- a/config/locales/views/settings/api_keys/pl.yml +++ b/config/locales/views/settings/api_keys/pl.yml @@ -37,7 +37,7 @@ pl: never_expires: Nigdy nie wygasa permissions: Uprawnienia usage_instructions_title: Jak używać klucza API - usage_instructions: 'Dołącz klucz API w nagłówku X-Api-Key podczas wysyłania żądań do API Maybe:' + usage_instructions: 'Dołącz klucz API w nagłówku X-Api-Key podczas wysyłania żądań do API %{product_name}:' regenerate_key: Utwórz nowy klucz revoke_key: Unieważnij klucz revoke_confirmation: Czy na pewno chcesz unieważnić ten klucz API? Tej akcji nie można cofnąć, a wszystkie aplikacje używające tego klucza zostaną natychmiast wyłączone. diff --git a/config/locales/views/settings/api_keys/tr.yml b/config/locales/views/settings/api_keys/tr.yml index 9a8457720..9aee48687 100644 --- a/config/locales/views/settings/api_keys/tr.yml +++ b/config/locales/views/settings/api_keys/tr.yml @@ -36,7 +36,7 @@ tr: never_expires: "Süresiz" permissions: "Yetkiler" usage_instructions_title: "API anahtarınızı nasıl kullanırsınız" - usage_instructions: "Maybe API'ye istek yaparken API anahtarınızı X-Api-Key başlığına ekleyin:" + usage_instructions: "%{product_name} API'ye istek yaparken API anahtarınızı X-Api-Key başlığına ekleyin:" regenerate_key: "Yeni Anahtar Oluştur" revoke_key: "Anahtarı İptal Et" revoke_confirmation: "Bu API anahtarını iptal etmek istediğinizden emin misiniz? Bu işlem geri alınamaz ve bu anahtarı kullanan tüm uygulamalar hemen devre dışı kalır." diff --git a/config/locales/views/settings/api_keys/zh-TW.yml b/config/locales/views/settings/api_keys/zh-TW.yml index 8ec8327ed..7f54ff28d 100644 --- a/config/locales/views/settings/api_keys/zh-TW.yml +++ b/config/locales/views/settings/api_keys/zh-TW.yml @@ -37,7 +37,7 @@ zh-TW: never_expires: "永不過期" permissions: "權限範圍" usage_instructions_title: "如何使用您的 API 金鑰" - usage_instructions: "在向 Maybe API 發送請求時,請在 X-Api-Key 標頭 (Header) 中包含您的 API 金鑰:" + usage_instructions: "在向 %{product_name} API 發送請求時,請在 X-Api-Key 標頭 (Header) 中包含您的 API 金鑰:" regenerate_key: "建立新金鑰" revoke_key: "撤銷金鑰" revoke_confirmation: "您確定要撤銷此 API 金鑰嗎?此操作無法還原,且會立即停用所有使用此金鑰的應用程式。" From bc3e5a824f5320b42898222ccf23cb6db00b989d Mon Sep 17 00:00:00 2001 From: Alessio Cappa <104093777+alessiocappa@users.noreply.github.com> Date: Tue, 26 May 2026 22:17:00 +0200 Subject: [PATCH 5/7] feat: Add pagination in merchants page (#1965) * feat: Add pagination in merchants page * fix: Add separate paginations for family/provider merchants * refactor: simplify conditions in view --- app/controllers/family_merchants_controller.rb | 11 +++++++---- app/views/family_merchants/index.html.erb | 16 ++++++++++++---- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/app/controllers/family_merchants_controller.rb b/app/controllers/family_merchants_controller.rb index db77ee413..d6420b01f 100644 --- a/app/controllers/family_merchants_controller.rb +++ b/app/controllers/family_merchants_controller.rb @@ -5,8 +5,8 @@ class FamilyMerchantsController < ApplicationController @breadcrumbs = [ [ t("breadcrumbs.home"), root_path ], [ t("breadcrumbs.merchants"), nil ] ] # Show all merchants for this family - @family_merchants = Current.family.merchants.alphabetically - @provider_merchants = Current.family.assigned_merchants_for(Current.user).where(type: "ProviderMerchant").alphabetically + @all_family_merchants = Current.family.merchants.alphabetically + @all_provider_merchants = Current.family.assigned_merchants_for(Current.user).where(type: "ProviderMerchant").alphabetically # Show recently unlinked ProviderMerchants (within last 30 days) # Exclude merchants that are already assigned to transactions (they appear in provider_merchants) @@ -14,12 +14,15 @@ class FamilyMerchantsController < ApplicationController .where(family: Current.family) .recently_unlinked .pluck(:merchant_id) - assigned_ids = @provider_merchants.pluck(:id) + assigned_ids = @all_provider_merchants.pluck(:id) @unlinked_merchants = ProviderMerchant.where(id: recently_unlinked_ids - assigned_ids).alphabetically - @enhanceable_count = @provider_merchants.where(website_url: [ nil, "" ]).count + @enhanceable_count = @all_provider_merchants.where(website_url: [ nil, "" ]).count @llm_available = Provider::Registry.get_provider(:openai).present? + @pagy_family_merchants, @family_merchants = pagy(@all_family_merchants, page_param: :family_page, limit: safe_per_page) + @pagy_provider_merchants, @provider_merchants = pagy(@all_provider_merchants, page_param: :provider_page, limit: safe_per_page) + render layout: "settings" end diff --git a/app/views/family_merchants/index.html.erb b/app/views/family_merchants/index.html.erb index e647e5833..ceb8cb07c 100644 --- a/app/views/family_merchants/index.html.erb +++ b/app/views/family_merchants/index.html.erb @@ -19,10 +19,10 @@

<%= t(".family_title", moniker: family_moniker) %>

· -

<%= @family_merchants.count %>

+

<%= @all_family_merchants.count %>

- <% if @family_merchants.any? %> + <% if @all_family_merchants.any? %>
@@ -52,13 +52,17 @@ <% end %> + +
+ <%= render "shared/pagination", pagy: @pagy_family_merchants %> +

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

· -

<%= @provider_merchants.count %>

+

<%= @all_provider_merchants.count %>

@@ -84,7 +88,7 @@
<% end %> - <% if @provider_merchants.any? %> + <% if @all_provider_merchants.any? %>
@@ -106,6 +110,10 @@

<%= t(".provider_empty", moniker: family_moniker_downcase) %>

<% end %> + +
+ <%= render "shared/pagination", pagy: @pagy_provider_merchants %> +
<% if @unlinked_merchants.any? %> From 3e2990a52c3f3166ab5700c2bb21643df2384ccb Mon Sep 17 00:00:00 2001 From: CrossDrain <32982516+CrossDrain@users.noreply.github.com> Date: Tue, 26 May 2026 20:48:23 +0000 Subject: [PATCH 6/7] feat(ibkr): compute net_market_flows from IBKR equity equity delta and trade flows (#1970) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ibkr): compute net_market_flows from IBKR equity delta and trade flows Replace the hardcoded net_market_flows: 0 in HistoricalBalancesSync with an exact derivation from IBKR's own equity summary data, eliminating any dependency on third-party security price providers for Period Return. Formula: nmf = Δnon_cash - net_buy_sell - non_cash = IBKR equity total - materializer cash (exact per IBKR) - net_buy_sell = sum of trade amounts converted to base currency using the stored fx_rate_to_base (IBKR's own FX rate, already on Trade#exchange_rate) Sets non_cash_adjustments = net_buy_sell so the virtual column identity (end_non_cash_balance = start + nmf + adjustments) resolves to IBKR's exact equity figure. * test(ibkr): add sell-trade and no-trade nmf tests; fix memoization guard - Add test: sell trades (negative amount) correctly isolate market loss in nmf - Add test: no-trade scenario produces nmf = full Δnon_cash - Fix: `return {} unless account` inside ||= exited the method without memoizing; restructure to `if account ... else {} end` so the result is always cached Co-Authored-By: Claude Sonnet 4.6 * fix(ibkr): exclude dividend/interest trades from net_buy_sell; use historical FX date Addresses two issues flagged in code review: - P1: trades with qty=0 (Dividend, Interest) were included in net_buy_sell, inflating/deflating nmf on dates with income events. Filter to qty != 0 at the SQL level so only buy/sell trades affect the market-flow calculation. - P2: Money#exchange_to defaulted to Date.current when no custom_rate was stored, causing historical nmf to drift as FX rates change over time. Pass date: entry.date so the fallback lookup uses the trade's own date. Co-Authored-By: Claude Sonnet 4.6 * test(ibkr): cover Money::ConversionError fallback in trade_flows_by_date Adds a test that stubs Money#exchange_to to raise ConversionError for a cross-currency trade with no stored exchange_rate, verifying that the rescue clause falls back to entry.amount and that nmf and end_non_cash_balance still resolve correctly. Co-Authored-By: Claude Sonnet 4.6 * fix(ibkr): log warning when FX conversion falls back to unconverted amount When Money::ConversionError is raised for a cross-currency trade with no stored exchange_rate, warn with entry currency, account currency, date, amount, and entry/account IDs so the silent fallback is visible in logs. Same-currency ConversionErrors (unexpected but possible) stay silent. Co-Authored-By: Claude Sonnet 4.6 * fix(ibkr): skip unconvertible FX trades, redact log, tighten join - On Money::ConversionError, skip the entry from net_buy_sell rather than falling back to the raw amount (which treated e.g. EUR as CHF); nmf now absorbs the full Δnon_cash for that date instead of silently misstating period return - Remove entry amount, entry ID, and account ID from the FX warning log to avoid exposing financial data in log output - Consolidate entryable_type guard into the JOIN condition rather than a separate WHERE clause - Add inline comment on the first-day zero case to distinguish intent from a bug - Update ConversionError test to assert skip behavior (nmf=200, not 50) * fix(ibkr): exclude dates with unconvertible FX trades from balance upsert * fix(ibkr): skip upsert_all when all balance rows are filtered by failed FX dates --------- Co-authored-by: Claude Sonnet 4.6 --- .../ibkr_account/historical_balances_sync.rb | 65 +++++++- .../historical_balances_sync_test.rb | 157 ++++++++++++++++++ 2 files changed, 218 insertions(+), 4 deletions(-) diff --git a/app/models/ibkr_account/historical_balances_sync.rb b/app/models/ibkr_account/historical_balances_sync.rb index 4d302d7ba..45155a26e 100644 --- a/app/models/ibkr_account/historical_balances_sync.rb +++ b/app/models/ibkr_account/historical_balances_sync.rb @@ -11,8 +11,11 @@ class IbkrAccount::HistoricalBalancesSync return unless account.present? return if normalized_rows.empty? + rows = balance_rows + return if rows.empty? + account.balances.upsert_all( - balance_rows, + rows, unique_by: %i[account_id date currency] ) end @@ -109,12 +112,35 @@ class IbkrAccount::HistoricalBalancesSync def balance_rows current_time = Time.current + trade_flows_by_date # ensure @failed_fx_dates is populated before iterating - normalized_rows.each_with_index.map do |row, index| + normalized_rows.each_with_index.filter_map do |row, index| + next if @failed_fx_dates.include?(row[:date]) previous_row = index.zero? ? nil : normalized_rows[index - 1] start_cash_balance = previous_row ? previous_row[:cash] : row[:cash] start_non_cash_balance = previous_row ? previous_row[:non_cash] : row[:non_cash] + # Derive market return directly from IBKR's equity data so Period Return + # matches IBKR without requiring third-party security price providers. + # + # nmf = Δnon_cash - net_buy_sell + # Δnon_cash : change in holdings value per IBKR equity summary (exact) + # net_buy_sell: sum of trade entry amounts converted to base currency + # (positive = buy, negative = sell; IBKR fx_rate_to_base applied) + # + # non_cash_adjustments absorbs net_buy_sell so the virtual column + # end_non_cash_balance = start + nmf + adjustments stays equal to row[:non_cash]. + if previous_row + net_buy_sell = trade_flows_by_date[row[:date]] || 0 + nmf = row[:non_cash] - start_non_cash_balance - net_buy_sell + non_cash_adj = net_buy_sell + else + # First-day row has no prior period to diff against, so both values are + # intentionally zero — not a bug, just an unavoidable bootstrap constraint. + nmf = 0 + non_cash_adj = 0 + end + { account_id: account.id, date: row[:date], @@ -127,13 +153,44 @@ class IbkrAccount::HistoricalBalancesSync cash_outflows: 0, non_cash_inflows: 0, non_cash_outflows: 0, - net_market_flows: 0, + net_market_flows: nmf, cash_adjustments: row[:cash] - start_cash_balance, - non_cash_adjustments: row[:non_cash] - start_non_cash_balance, + non_cash_adjustments: non_cash_adj, flows_factor: 1, created_at: current_time, updated_at: current_time } end end + + # Net value of all trades on each date, in account base currency. + # Uses the IBKR-provided fx_rate_to_base stored on each Trade entry so the + # conversion is exact and consistent with IBKR's own calculations. + # Positive = net buy (cash out), negative = net sell (cash in). + def trade_flows_by_date + @trade_flows_by_date ||= begin + @failed_fx_dates = [] + if account + account.entries + .joins("INNER JOIN trades ON trades.id = entries.entryable_id AND entries.entryable_type = 'Trade'") + .where.not(trades: { qty: 0 }) + .includes(:entryable) + .each_with_object(Hash.new(0)) do |entry, flows| + custom_rate = entry.entryable.exchange_rate + base_amount = Money.new(entry.amount, entry.currency) + .exchange_to(account_currency, custom_rate: custom_rate, date: entry.date) + .amount + flows[entry.date] += base_amount + rescue Money::ConversionError + Rails.logger.warn( + "IbkrAccount::HistoricalBalancesSync - No FX rate for #{entry.currency}→#{account_currency} " \ + "on #{entry.date}; balance row for this date will not be persisted" + ) + @failed_fx_dates << entry.date + end + else + {} + end + end + end end diff --git a/test/models/ibkr_account/historical_balances_sync_test.rb b/test/models/ibkr_account/historical_balances_sync_test.rb index 66b5ee89b..825cc1aaf 100644 --- a/test/models/ibkr_account/historical_balances_sync_test.rb +++ b/test/models/ibkr_account/historical_balances_sync_test.rb @@ -213,6 +213,163 @@ class IbkrAccount::HistoricalBalancesSyncTest < ActiveSupport::TestCase assert_not_nil @account.balances.find_by(date: Date.new(2026, 5, 8), currency: "CHF") end + test "computes net_market_flows from equity delta minus trade flows" do + # Day 1: total=3000, cash=500, non_cash=2500 + # Day 2: total=3200, cash=500, non_cash=2700 (Δnon_cash=200) + # Buy trade on Day 2: CHF 150 (same currency as account, no FX) + # nmf = 200 - 150 = 50 + @ibkr_account.update!( + raw_equity_summary_payload: [ + { report_date: "2026-05-07", total: "3000.00" }, + { report_date: "2026-05-08", total: "3200.00" } + ] + ) + seed_balance(date: Date.new(2026, 5, 7), balance: 3000.00, cash_balance: 500.00) + seed_balance(date: Date.new(2026, 5, 8), balance: 3200.00, cash_balance: 500.00) + + security = Security.create!(ticker: "TEST", name: "Test Stock") + @account.entries.create!( + name: "Buy 100 TEST", + date: Date.new(2026, 5, 8), + amount: 150.00, + currency: "CHF", + entryable: Trade.new(security: security, qty: 100, price: 1.5, currency: "CHF") + ) + + IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync! + + day1 = @account.balances.find_by!(date: Date.new(2026, 5, 7), currency: "CHF") + day2 = @account.balances.find_by!(date: Date.new(2026, 5, 8), currency: "CHF") + + assert_equal BigDecimal("0"), day1.net_market_flows + assert_equal BigDecimal("50"), day2.net_market_flows + + # Virtual column must still resolve to IBKR's equity total minus cash + assert_equal BigDecimal("2500.00"), day1.end_non_cash_balance + assert_equal BigDecimal("2700.00"), day2.end_non_cash_balance + end + + test "applies fx_rate_to_base when trade currency differs from account currency" do + # Trade in EUR with fx_rate_to_base=1.1 → CHF 165, not CHF 150 + # Day 1: non_cash=2500, Day 2: non_cash=2700 (Δ=200) + # nmf = 200 - 165 = 35 + @ibkr_account.update!( + raw_equity_summary_payload: [ + { report_date: "2026-05-07", total: "3000.00" }, + { report_date: "2026-05-08", total: "3200.00" } + ] + ) + seed_balance(date: Date.new(2026, 5, 7), balance: 3000.00, cash_balance: 500.00) + seed_balance(date: Date.new(2026, 5, 8), balance: 3200.00, cash_balance: 500.00) + + security = Security.create!(ticker: "TEST2", name: "Test Stock EUR") + trade = Trade.new(security: security, qty: 100, price: 1.5, currency: "EUR") + trade.exchange_rate = 1.1 + @account.entries.create!( + name: "Buy 100 TEST2", + date: Date.new(2026, 5, 8), + amount: 150.00, + currency: "EUR", + entryable: trade + ) + + IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync! + + day2 = @account.balances.find_by!(date: Date.new(2026, 5, 8), currency: "CHF") + assert_in_delta 35.0, day2.net_market_flows.to_f, 0.01 + assert_equal BigDecimal("2700.00"), day2.end_non_cash_balance + end + + test "excludes balance row from upsert when Money::ConversionError prevents FX conversion" do + # EUR trade with no exchange_rate stored → custom_rate=nil → ConversionError raised. + # The affected date is excluded from the upsert entirely so net_market_flows is not + # silently wrong (the trade's value would otherwise flow into market appreciation). + # The seeded day2 balance is intentionally different from IBKR's total (3150 vs 3200) + # so we can assert the row was not overwritten by sync. + @ibkr_account.update!( + raw_equity_summary_payload: [ + { report_date: "2026-05-07", total: "3000.00" }, + { report_date: "2026-05-08", total: "3200.00" } + ] + ) + seed_balance(date: Date.new(2026, 5, 7), balance: 3000.00, cash_balance: 500.00) + seed_balance(date: Date.new(2026, 5, 8), balance: 3150.00, cash_balance: 500.00) + + security = Security.create!(ticker: "NORATE", name: "No Rate EUR Stock") + @account.entries.create!( + name: "Buy 100 NORATE", + date: Date.new(2026, 5, 8), + amount: 150.00, + currency: "EUR", + entryable: Trade.new(security: security, qty: 100, price: 1.5, currency: "EUR") + ) + + Money.any_instance.stubs(:exchange_to).raises( + Money::ConversionError.new(from_currency: "EUR", to_currency: "CHF", date: Date.new(2026, 5, 8)) + ) + + IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync! + + # Day 1 is unaffected — still synced normally + day1 = @account.balances.find_by!(date: Date.new(2026, 5, 7), currency: "CHF") + assert_equal BigDecimal("3000.00"), day1.balance + + # Day 2 was excluded from the upsert — seeded values are preserved, not overwritten + day2 = @account.balances.find_by!(date: Date.new(2026, 5, 8), currency: "CHF") + assert_equal BigDecimal("3150.00"), day2.balance # seeded, not IBKR's 3200 + assert_equal BigDecimal("0"), day2.net_market_flows # seeded, not recomputed + end + + test "net_market_flows equals full non_cash delta when account has no trades" do + @ibkr_account.update!( + raw_equity_summary_payload: [ + { report_date: "2026-05-07", total: "3000.00" }, + { report_date: "2026-05-08", total: "3300.00" } + ] + ) + seed_balance(date: Date.new(2026, 5, 7), balance: 3000.00, cash_balance: 500.00) + seed_balance(date: Date.new(2026, 5, 8), balance: 3300.00, cash_balance: 500.00) + + IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync! + + day1 = @account.balances.find_by!(date: Date.new(2026, 5, 7), currency: "CHF") + day2 = @account.balances.find_by!(date: Date.new(2026, 5, 8), currency: "CHF") + + assert_equal BigDecimal("0"), day1.net_market_flows + assert_equal BigDecimal("300"), day2.net_market_flows + assert_equal BigDecimal("2800.00"), day2.end_non_cash_balance + end + + test "sell trades reduce net_buy_sell so market loss is isolated in net_market_flows" do + # Day 1: total=3000, cash=500, non_cash=2500 + # Day 2: total=2700, cash=700, non_cash=2000 (Δnon_cash=-500) + # Sell 100 at CHF 1.50: entry.amount=-150 (negative = proceeds received) + # net_buy_sell=-150; nmf = -500 - (-150) = -350 (market caused -350 loss) + @ibkr_account.update!( + raw_equity_summary_payload: [ + { report_date: "2026-05-07", total: "3000.00" }, + { report_date: "2026-05-08", total: "2700.00" } + ] + ) + seed_balance(date: Date.new(2026, 5, 7), balance: 3000.00, cash_balance: 500.00) + seed_balance(date: Date.new(2026, 5, 8), balance: 2700.00, cash_balance: 700.00) + + security = Security.create!(ticker: "SELL_TEST", name: "Sell Test Stock") + @account.entries.create!( + name: "Sell 100 SELL_TEST", + date: Date.new(2026, 5, 8), + amount: -150.00, + currency: "CHF", + entryable: Trade.new(security: security, qty: -100, price: 1.5, currency: "CHF") + ) + + IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync! + + day2 = @account.balances.find_by!(date: Date.new(2026, 5, 8), currency: "CHF") + assert_equal BigDecimal("-350"), day2.net_market_flows + assert_equal BigDecimal("2000.00"), day2.end_non_cash_balance + end + test "writes balance row with zero total for fully liquidated dates" do @ibkr_account.update!( raw_equity_summary_payload: [ From 0342958a324a3629936acbe88beced1a671fbc65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Wed, 27 May 2026 09:35:10 +0200 Subject: [PATCH 7/7] Create SECURITY.md template for security policy and reporting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added a security policy document outlining supported versions and vulnerability reporting. Signed-off-by: Juan José Mata --- SECURITY.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..034e84803 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security Policy + +## Supported Versions + +Use this section to tell people about which versions of your project are +currently being supported with security updates. + +| Version | Supported | +| ------- | ------------------ | +| 5.1.x | :white_check_mark: | +| 5.0.x | :x: | +| 4.0.x | :white_check_mark: | +| < 4.0 | :x: | + +## Reporting a Vulnerability + +Use this section to tell people how to report a vulnerability. + +Tell them where to go, how often they can expect to get an update on a +reported vulnerability, what to expect if the vulnerability is accepted or +declined, etc.