diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml index 290a0c5e0..0cfdb6882 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 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. 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/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/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/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 %>