Files
sure/app/helpers/settings_helper.rb
ghost e59235fdc5 feat(statements): add account statement vault (#1753)
* feat(statements): add account statement vault

Add web-only statement uploads, account linking, duplicate detection, and per-account coverage/reconciliation checks without mutating transactions. Extend ActiveStorage authorization and targeted tests for family/account scoping.

* fix(statements): return deleted account statements to inbox

Preserve linked statement records when an account is deleted by moving them back to the unmatched inbox, then expand coverage for upload validation, sanitized parser metadata, unavailable reconciliation, and missing-month coverage.

* fix(statements): harden vault upload review flows

Address review and security findings in the statement vault by preserving sanitized parser metadata, failing closed on orphaned statement blobs, avoiding account_id mass assignment permits, and adding regression coverage for link/delete edge cases.

* fix(statements): harden vault upload and access controls

* fix(statements): address vault hardening review

* fix(statements): address vault review feedback

Prioritize SHA-256 duplicate detection while preserving MD5 fallback for legacy rows.

Remove free-form account notes from statement matching, document direct account-destroy unlinking, and add year-selectable historical coverage with muted out-of-range months.

* fix(statements): harden vault review follow-ups

Clarify legacy MD5 checksum use, whitelist statement balance helper dispatch, and preserve sanitized parser metadata.

Hide statement management controls from read-only viewers while keeping server-side authorization unchanged.

* fix(statements): repair settings system coverage

Allow the changelog provider lookup in the self-hosting settings system test, include Statement Vault in settings navigation coverage, and align the feature title casing. Update the devcontainer so ActiveStorage and parallel system tests can run in the documented environment.

* fix(statements): move vault beside accounts

Place Statement Vault with account settings instead of between Imports and Exports. Keep settings footer ordering and system navigation coverage aligned, including the non-admin visibility guard.

* fix(statements): address vault review cleanup

Resolve CodeRabbit review feedback for statement upload validation, duplicate race handling, account statement matching semantics, metadata detection, ActiveStorage authorization tests, and small UI/style cleanups.

* fix(statements): address vault cleanup review

* fix(statements): deduplicate vault style helpers

* fix(statements): close vault review follow-ups

* fix(statements): refresh schema after upstream rebase

* fix(statements): process vault uploads sequentially

* fix(statements): close vault review follow-ups

* fix(statements): scope vault index to accessible accounts

* fix(statements): harden statement vault readiness

Squash the statement vault migration hardening into the feature migration, tighten Active Storage authorization edge cases, bound CSV metadata detection, and add real PDF fixture coverage for stored statements.

Validation: targeted statement/auth/controller/provider tests, full Rails suite, system tests, RuboCop, Biome, Brakeman, Zeitwerk, importmap audit, npm audit, ERB lint, CodeRabbit, and Codex Security all passed locally.

* fix(statements): close vault review follow-ups

Move statement unlinking to after account destroy commit, keep Kraken account creation on the shared crypto helper, and add statement metadata length limits with DB checks.

Validation: fresh devcontainer with fresh DB via db:prepare, focused account/statement/Kraken/Binance tests, RuboCop, Brakeman, Zeitwerk, git diff --check, CodeRabbit, and Codex Security passed before commit.

* fix(statements): address vault scan follow-ups

Move statement tab data setup out of the ERB partial, harden reconciliation labels and coverage initialization, and tighten statement schema constraints.

Validation: CodeRabbit and Codex Security reviewed the current PR diff; Rails focused tests, full Rails tests, system tests, RuboCop, Brakeman, Zeitwerk, ERB lint, npm lint, importmap audit, npm audit, and git diff --check passed.

* fix(statements): defer vault tab loading

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-05-13 21:05:11 +02:00

240 lines
9.8 KiB
Ruby

module SettingsHelper
SETTINGS_ORDER = [
# General section
{ name: -> { t("settings.settings_nav.accounts_label") }, path: :accounts_path },
{ name: -> { t("settings.settings_nav.bank_sync_label") }, path: :settings_providers_path, condition: :admin_user? },
{ name: -> { t("settings.settings_nav.preferences_label") }, path: :settings_preferences_path },
{ name: -> { t("settings.settings_nav.appearance_label") }, path: :settings_appearance_path },
{ name: -> { t("settings.settings_nav.profile_label") }, path: :settings_profile_path },
{ name: -> { t("settings.settings_nav.security_label") }, path: :settings_security_path },
{ name: -> { t("settings.settings_nav.payment_label") }, path: :settings_payment_path, condition: :not_self_hosted? },
# Transactions section
{ name: -> { t("settings.settings_nav.categories_label") }, path: :categories_path },
{ name: -> { t("settings.settings_nav.tags_label") }, path: :tags_path },
{ name: -> { t("settings.settings_nav.rules_label") }, path: :rules_path },
{ name: -> { t("settings.settings_nav.merchants_label") }, path: :family_merchants_path },
{ name: -> { t("settings.settings_nav.recurring_transactions_label") }, path: :recurring_transactions_path },
{ name: -> { t("settings.settings_nav.statement_vault_label") }, path: :account_statements_path, condition: :admin_user? },
# Advanced section
{ name: -> { t("settings.settings_nav.ai_prompts_label") }, path: :settings_ai_prompts_path, condition: :admin_user? },
{ name: -> { t("settings.settings_nav.llm_usage_label") }, path: :settings_llm_usage_path, condition: :admin_user? },
{ name: -> { t("settings.settings_nav.api_key_label") }, path: :settings_api_key_path, condition: :admin_user? },
{ name: -> { t("settings.settings_nav.self_hosting_label") }, path: :settings_hosting_path, condition: :self_hosted_and_admin? },
{ name: -> { t("settings.settings_nav.imports_label") }, path: :imports_path, condition: :admin_user? },
{ name: -> { t("settings.settings_nav.exports_label") }, path: :family_exports_path, condition: :admin_user? },
# More section
{ name: -> { t("settings.settings_nav.guides_label") }, path: :settings_guides_path },
{ name: -> { t("settings.settings_nav.whats_new_label") }, path: :changelog_path },
{ name: -> { t("settings.settings_nav.feedback_label") }, path: :feedback_path }
]
def adjacent_setting(current_path, offset)
visible_settings = SETTINGS_ORDER.select { |setting| setting[:condition].nil? || send(setting[:condition]) }
current_index = visible_settings.index { |setting| send(setting[:path]) == current_path }
return nil unless current_index
adjacent_index = current_index + offset
return nil if adjacent_index < 0 || adjacent_index >= visible_settings.size
adjacent = visible_settings[adjacent_index]
render partial: "settings/settings_nav_link_large", locals: {
path: send(adjacent[:path]),
direction: offset > 0 ? "next" : "previous",
title: setting_name(adjacent)
}
end
def settings_section(title:, subtitle: nil, collapsible: false, open: true, auto_open_param: nil, status: nil, meta: nil, actions: nil, badge: nil, &block)
content = capture(&block)
render partial: "settings/section", locals: { title: title, subtitle: subtitle, content: content, collapsible: collapsible, open: open, auto_open_param: auto_open_param, status: status, meta: meta, actions: actions, badge: badge }
end
def status_pill_classes(status)
pill = "bg-surface-inset text-primary"
case status.to_s.to_sym
when :ok
{ dot: "bg-success", pill: pill }
when :warn
{ dot: "bg-warning", pill: pill }
when :err
{ dot: "bg-destructive", pill: pill }
else
{ dot: "bg-gray-400", pill: pill }
end
end
def provider_summary(provider_key)
key = provider_key.to_s.downcase
case key
when "plaid", "plaid_eu"
configured = @provider_configurations&.find { |c| c.provider_key.to_s.casecmp(key).zero? }&.configured?
configured ? { status: :ok } : { status: :off }
when "simplefin"
return { status: :off } unless @simplefin_items&.any?
sync_based_summary(key)
when "lunchflow"
return { status: :off } unless @lunchflow_items&.any?
sync_based_summary(key)
when "enable_banking"
return { status: :off } unless @enable_banking_items&.any?
enable_banking_summary
when "coinstats"
return { status: :off } unless @coinstats_items&.any?
sync_based_summary(key)
when "mercury"
return { status: :off } unless @mercury_items&.any?
sync_based_summary(key)
when "brex"
return { status: :off } unless @brex_items&.any?
sync_based_summary(key)
when "coinbase"
return { status: :off } unless @coinbase_items&.any?
sync_based_summary(key)
when "binance"
return { status: :off } unless @binance_items&.any?
sync_based_summary(key)
when "kraken"
return { status: :off } unless @kraken_items&.any?
sync_based_summary(key)
when "snaptrade"
configured_item = @snaptrade_items&.find(&:credentials_configured?)
return { status: :off } unless configured_item
unless configured_item.user_registered?
return { status: :warn, meta: t("settings.providers.meta.registration_needed") }
end
sync_based_summary(key)
when "ibkr"
return { status: :off } unless @ibkr_items&.any?
sync_based_summary(key)
when "indexa_capital"
return { status: :off } unless @indexa_capital_items&.any?
sync_based_summary(key)
when "sophtron"
return { status: :off } unless @sophtron_items&.any?
sync_based_summary(key)
else
{ status: :off }
end
end
def settings_nav_footer
previous_setting = adjacent_setting(request.path, -1)
next_setting = adjacent_setting(request.path, 1)
content_tag :div, class: "hidden md:flex flex-row justify-between gap-4" do
concat(previous_setting)
concat(next_setting)
end
end
def settings_nav_footer_mobile
previous_setting = adjacent_setting(request.path, -1)
next_setting = adjacent_setting(request.path, 1)
content_tag :div, class: "md:hidden flex flex-col gap-4 pb-[env(safe-area-inset-bottom)]" do
concat(previous_setting)
concat(next_setting)
end
end
# Below this many synced accounts, the per-row pills already give the user
# enough at-a-glance signal and the strip is redundant chrome.
HEALTH_STRIP_MIN_ACCOUNTS = 10
# Slim health-strip data for the providers index. Pulls counts from the
# already-resolved entry summaries plus the family's distinct synced-account
# count for the trailing stat. Returns a hash consumed by the
# `settings/providers/_health_strip` partial, or nil when the family has
# fewer than HEALTH_STRIP_MIN_ACCOUNTS connected accounts.
def provider_health_strip(connected:, needs_attention:)
accounts_count = Current.family.accounts.joins(:account_providers).distinct.count
return nil if accounts_count < HEALTH_STRIP_MIN_ACCOUNTS
active_entries = connected + needs_attention
last_synced_at = active_entries.map { |e| e[:summary][:last_synced_at] }.compact.max
{
connected: active_entries.size,
needs_attention: needs_attention.size,
accounts_syncing: accounts_count,
last_synced_at: last_synced_at
}
end
# Strips the leading "about " from `time_ago_in_words` so copy reads as
# "Synced 6 hours ago" instead of "Synced about 6 hours ago".
def concise_time_ago(time)
time_ago_in_words(time).sub(/\Aabout /, "")
end
private
def sync_based_summary(provider_key)
health = @provider_sync_health&.dig(provider_key) || {}
last_synced_at = health[:last_synced_at]
base = if health[:error]
{ status: :err, meta: t("settings.providers.meta.sync_error") }
elsif health[:stale]
{ status: :warn, meta: t("settings.providers.meta.no_recent_sync") }
elsif last_synced_at.present?
{ status: :ok, meta: t("settings.providers.meta.last_synced", time: concise_time_ago(last_synced_at)) }
else
{ status: :ok }
end
base.merge(last_synced_at: last_synced_at)
end
def enable_banking_summary
health = @provider_sync_health&.dig("enable_banking") || {}
last_synced_at = health[:last_synced_at]
return { status: :err, meta: t("settings.providers.meta.sync_error"), last_synced_at: nil } if health[:error]
valid_items = @enable_banking_items&.select(&:session_valid?) || []
# All items have expired/missing sessions — need re-authorization
if valid_items.empty?
return { status: :warn, meta: t("settings.providers.meta.reconsent_required"), last_synced_at: last_synced_at }
end
expiring = valid_items.find do |item|
item.session_expires_at.present? && item.session_expires_at < 7.days.from_now
end
if expiring
days = [ ((expiring.session_expires_at - Time.current) / 1.day).ceil, 1 ].max
return { status: :warn, meta: t("settings.providers.meta.reconsent_needed", count: days), last_synced_at: last_synced_at }
end
return { status: :warn, meta: t("settings.providers.meta.no_recent_sync"), last_synced_at: last_synced_at } if health[:stale]
if last_synced_at.present?
{ status: :ok, meta: t("settings.providers.meta.last_synced", time: concise_time_ago(last_synced_at)), last_synced_at: last_synced_at }
else
{ status: :ok, last_synced_at: nil }
end
end
def not_self_hosted?
!self_hosted?
end
def setting_name(setting)
name = setting[:name]
name.respond_to?(:call) ? instance_exec(&name) : name
end
# Helper used by SETTINGS_ORDER conditions
def admin_user?
Current.user&.admin?
end
def self_hosted_and_admin?
self_hosted? && admin_user?
end
end