diff --git a/.github/workflows/label-not-gittensor.yml b/.github/workflows/label-not-gittensor.yml new file mode 100644 index 000000000..7b846e8f7 --- /dev/null +++ b/.github/workflows/label-not-gittensor.yml @@ -0,0 +1,60 @@ +name: Label non-Gittensor PRs + +on: + pull_request_target: + types: + - opened + - reopened + +permissions: + pull-requests: write + +jobs: + label-pr: + runs-on: ubuntu-latest + steps: + - name: Add not-gittensor label for matched authors + uses: actions/github-script@v7 + env: + GITTENSOR_USERS: ${{ vars.GITTENSOR_USERS || '[]' }} + GITTENSOR_EXCEPTIONS: ${{ vars.GITTENSOR_EXCEPTIONS || '[]' }} + TARGET_LABEL: not-gittensor + with: + script: | + const parseList = (raw, name) => { + try { + const parsed = JSON.parse(raw || '[]'); + if (!Array.isArray(parsed)) { + core.setFailed(`${name} must be a JSON array.`); + return []; + } + return parsed.map((value) => String(value).toLowerCase()); + } catch (error) { + core.setFailed(`Failed to parse ${name}: ${error.message}`); + return []; + } + }; + + const author = context.payload.pull_request.user.login.toLowerCase(); + const users = new Set(parseList(process.env.GITTENSOR_USERS, 'GITTENSOR_USERS')); + const exceptions = new Set(parseList(process.env.GITTENSOR_EXCEPTIONS, 'GITTENSOR_EXCEPTIONS')); + + if (users.has(author) || exceptions.has(author)) { + core.info(`No label needed for @${author}.`); + return; + } + + const existingLabels = context.payload.pull_request.labels.map((label) => label.name); + if (existingLabels.includes(process.env.TARGET_LABEL)) { + core.info(`Label ${process.env.TARGET_LABEL} already present.`); + return; + } + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + labels: [process.env.TARGET_LABEL], + }); + + core.info(`Added ${process.env.TARGET_LABEL} to PR #${context.payload.pull_request.number}.`); 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 diff --git a/.sure-version b/.sure-version index 38d939e70..dafd26204 100644 --- a/.sure-version +++ b/.sure-version @@ -1 +1 @@ -0.7.1-alpha.10 +0.7.1-alpha.11 diff --git a/Dockerfile b/Dockerfile index 2cb2c83e7..c165b18f7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,7 @@ WORKDIR /rails # Install base packages RUN apt-get update -qq \ - && apt-get install --no-install-recommends -y curl libvips postgresql-client libyaml-0-2 procps \ + && apt-get install --no-install-recommends -y curl libvips postgresql-client libyaml-0-2 procps libjemalloc2 \ && rm -rf /var/lib/apt/lists /var/cache/apt/archives # Set production environment @@ -19,7 +19,7 @@ ENV RAILS_ENV="production" \ BUNDLE_PATH="/usr/local/bundle" \ BUNDLE_WITHOUT="development" \ BUILD_COMMIT_SHA=${BUILD_COMMIT_SHA} - + # Throw-away build stage to reduce size of final image FROM base AS build 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/messages_controller.rb b/app/controllers/messages_controller.rb index b8041ad3d..f5e98ae41 100644 --- a/app/controllers/messages_controller.rb +++ b/app/controllers/messages_controller.rb @@ -4,13 +4,17 @@ class MessagesController < ApplicationController before_action :set_chat def create - @message = UserMessage.create!( + @message = UserMessage.new( chat: @chat, content: message_params[:content], ai_model: message_params[:ai_model].presence || Chat.default_model ) - redirect_to chat_path(@chat, thinking: true) + if @message.save + redirect_to chat_path(@chat, thinking: true) + else + redirect_to chat_path(@chat), alert: @message.errors.full_messages.to_sentence + end end private 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/controllers/trades_controller.rb b/app/controllers/trades_controller.rb index e3a90121e..e3aed40db 100644 --- a/app/controllers/trades_controller.rb +++ b/app/controllers/trades_controller.rb @@ -35,7 +35,7 @@ class TradesController < ApplicationController format.turbo_stream { stream_redirect_back_or_to account_path(@account) } end else - render :new, status: :unprocessable_entity + render :new, status: :unprocessable_entity, formats: [ :html ] end end @@ -69,7 +69,7 @@ class TradesController < ApplicationController end end else - render :show, status: :unprocessable_entity + render :show, status: :unprocessable_entity, formats: [ :html ] end end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index d265734c6..ab88dcc15 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -27,21 +27,27 @@ class UsersController < ApplicationController end else was_ai_enabled = @user.ai_enabled - @user.update!(user_params.except(:redirect_to, :delete_profile_image)) - @user.profile_image.purge if should_purge_profile_image? + if @user.update(user_params.except(:redirect_to, :delete_profile_image)) + @user.profile_image.purge if should_purge_profile_image? - # Add a special notice if AI was just enabled or disabled - notice = if !was_ai_enabled && @user.ai_enabled - "AI Assistant has been enabled successfully." - elsif was_ai_enabled && !@user.ai_enabled - "AI Assistant has been disabled." + # Add a special notice if AI was just enabled or disabled + notice = if !was_ai_enabled && @user.ai_enabled + "AI Assistant has been enabled successfully." + elsif was_ai_enabled && !@user.ai_enabled + "AI Assistant has been disabled." + else + t(".success") + end + + respond_to do |format| + format.html { handle_redirect(notice) } + format.json { head :ok } + end else - t(".success") - end - - respond_to do |format| - format.html { handle_redirect(notice) } - format.json { head :ok } + respond_to do |format| + format.html { redirect_to settings_profile_path, alert: @user.errors.full_messages.to_sentence } + format.json { render json: { errors: @user.errors.full_messages }, status: :unprocessable_entity } + end end end end diff --git a/app/javascript/controllers/bank_search_controller.js b/app/javascript/controllers/bank_search_controller.js index a2c045cd9..155a7a8bb 100644 --- a/app/javascript/controllers/bank_search_controller.js +++ b/app/javascript/controllers/bank_search_controller.js @@ -7,9 +7,9 @@ export default class extends Controller { const query = this.inputTarget.value.toLocaleLowerCase().trim(); let visibleCount = 0; - this.itemTargets.forEach(item => { - const name = item.dataset.bankName?.toLocaleLowerCase() ?? ""; - const match = name.includes(query); + this.itemTargets.forEach((item) => { + const haystack = (item.dataset.bankSearch ?? "").toLocaleLowerCase(); + const match = haystack.includes(query); item.style.display = match ? "" : "none"; if (match) visibleCount++; }); diff --git a/app/models/account.rb b/app/models/account.rb index b0595d308..6e11de9c4 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -2,7 +2,10 @@ class Account < ApplicationRecord include AASM, Syncable, Monetizable, Chartable, Linkable, Enrichable, Anchorable, Reconcileable, TaxTreatable before_validation :assign_default_owner, if: -> { owner_id.blank? } + before_destroy :capture_account_statement_ids_to_move + before_destroy :cleanup_transfers + after_destroy_commit :move_account_statements_to_inbox validates :name, :balance, :currency, presence: true @@ -543,4 +546,12 @@ class Account < ApplicationRecord updated_at: Time.current ) end + + def cleanup_transfers + transaction_ids = entries.where(entryable_type: "Transaction").pluck(:entryable_id) + + transfers = Transfer.where(inflow_transaction_id: transaction_ids).or(Transfer.where(outflow_transaction_id: transaction_ids)) + + transfers.find_each(&:destroy!) + end 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/models/transfer.rb b/app/models/transfer.rb index 878e899be..9b39d0f7d 100644 --- a/app/models/transfer.rb +++ b/app/models/transfer.rb @@ -38,8 +38,16 @@ class Transfer < ApplicationRecord # Once transfer is destroyed, we need to mark the denormalized kind fields on the transactions def destroy! Transfer.transaction do - inflow_transaction.update!(kind: "standard") - outflow_transaction.update!(kind: "standard") + [ inflow_transaction, outflow_transaction ].each do |transaction| + next if transaction.nil? + next unless Transaction.exists?(transaction.id) + begin + transaction.update!(kind: "standard") + rescue ActiveRecord::RecordNotFound + rescue NoMethodError + next + end + end super end end diff --git a/app/models/user.rb b/app/models/user.rb index b1c40bb76..74016d755 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -51,6 +51,7 @@ class User < ApplicationRecord validates :password, length: { minimum: 8 }, allow_nil: true normalizes :email, with: ->(email) { email.strip.downcase } normalizes :unconfirmed_email, with: ->(email) { email&.strip&.downcase } + normalizes :locale, with: ->(locale) { locale.presence } normalizes :first_name, :last_name, with: ->(value) { value.strip.presence } diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb index c31a1fb61..5e5a0ec0a 100644 --- a/app/views/admin/users/index.html.erb +++ b/app/views/admin/users/index.html.erb @@ -12,7 +12,7 @@ <%= f.label :role, t(".filters.role"), class: "block text-sm font-medium text-primary mb-1" %> <%= f.select :role, options_for_select( - [[t(".filters.role_all"), ""], [t(".roles.guest"), "guest"], [t(".roles.member", default: "Member"), "member"], [t(".roles.admin"), "admin"], [t(".roles.super_admin"), "super_admin"]], + [[t(".filters.role_all"), ""], [t(".roles.guest"), "guest"], [t(".roles.member"), "member"], [t(".roles.admin"), "admin"], [t(".roles.super_admin"), "super_admin"]], params[:role] ), {}, @@ -75,7 +75,7 @@ <% elsif sub %> "> + <%= sub.active? ? "bg-success/10 text-success" : "bg-surface text-secondary" %>"> <%= sub.status.humanize %> <% else %> @@ -87,7 +87,7 @@
- + @@ -123,7 +123,7 @@ <%= form.select :role, options_for_select([ [t(".roles.guest"), "guest"], - [t(".roles.member", default: "Member"), "member"], + [t(".roles.member"), "member"], [t(".roles.admin"), "admin"], [t(".roles.super_admin"), "super_admin"] ], user.role), @@ -139,7 +139,7 @@ <% if pending_invitations.any? %> <% pending_invitations.each do |invitation| %> - +
<%= t(".table.user") %> <%= t(".table.last_login") %>
<%= icon "mail", class: "w-5 h-5 text-secondary shrink-0" %> @@ -160,7 +160,7 @@ <% end %> @@ -198,9 +198,9 @@
- <%= t(".roles.member", default: "Member") %> + <%= t(".roles.member") %> -

<%= t(".role_descriptions.member", default: "Basic user access. Can manage their own accounts, transactions, and settings.") %>

+

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

@@ -209,7 +209,7 @@

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

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

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

diff --git a/app/views/enable_banking_items/select_bank.html.erb b/app/views/enable_banking_items/select_bank.html.erb index 124c8e3ec..095ae6738 100644 --- a/app/views/enable_banking_items/select_bank.html.erb +++ b/app/views/enable_banking_items/select_bank.html.erb @@ -28,7 +28,7 @@
<% @aspsps.each do |aspsp| %> -
+
"> <%= button_to authorize_enable_banking_item_path(@enable_banking_item), method: :post, params: { aspsp_name: aspsp[:name], new_connection: @new_connection }, 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? %> diff --git a/app/views/import/confirms/show.html.erb b/app/views/import/confirms/show.html.erb index 8560b761d..43afe9e70 100644 --- a/app/views/import/confirms/show.html.erb +++ b/app/views/import/confirms/show.html.erb @@ -67,7 +67,7 @@ <%= t(".#{step_mapping_class.name.demodulize.underscore}_title", import_type: @import.type.underscore.humanize) %>

- <%= t(".#{step_mapping_class.name.demodulize.underscore}_description", import_type: @import.type.underscore.humanize, product: product_name) %> + <%= t(".#{step_mapping_class.name.demodulize.underscore}_description", import_type: @import.type.underscore.humanize, product_name: product_name) %>

diff --git a/app/views/imports/new.html.erb b/app/views/imports/new.html.erb index e71d84b6f..78950bd76 100644 --- a/app/views/imports/new.html.erb +++ b/app/views/imports/new.html.erb @@ -122,7 +122,7 @@ <%= render "imports/import_option", type: "TransactionImport", icon_name: "bar-chart-2", - icon_bg_class: "bg-gray-tint-5", + icon_bg_class: "bg-surface-inset", icon_text_class: "text-subdued", label: t(".import_ynab"), enabled: false, diff --git a/app/views/settings/hostings/_brand_fetch_settings.html.erb b/app/views/settings/hostings/_brand_fetch_settings.html.erb index 03c52701f..b58ccfd01 100644 --- a/app/views/settings/hostings/_brand_fetch_settings.html.erb +++ b/app/views/settings/hostings/_brand_fetch_settings.html.erb @@ -34,6 +34,7 @@ <%= form.text_field :brand_fetch_client_id, label: t(".label"), type: "password", + autocomplete: "new-password", placeholder: t(".placeholder"), value: ENV.fetch("BRAND_FETCH_CLIENT_ID", Setting.brand_fetch_client_id), disabled: ENV["BRAND_FETCH_CLIENT_ID"].present?, 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/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 %> +
diff --git a/app/views/transactions/_transfer_match.html.erb b/app/views/transactions/_transfer_match.html.erb index f2d39531d..c7c8cf8de 100644 --- a/app/views/transactions/_transfer_match.html.erb +++ b/app/views/transactions/_transfer_match.html.erb @@ -6,11 +6,21 @@ <%= icon "link-2", size: "sm", class: "text-secondary" %> <% elsif transaction.transfer.pending? %> -