From 89eb4411451d4ea43e345a515c3133d362618090 Mon Sep 17 00:00:00 2001 From: "Sure Admin (bot)" Date: Mon, 15 Jun 2026 20:09:39 +0200 Subject: [PATCH 01/11] fix(sync): discover nightly provider items reflectively (#2334) --- app/models/family/syncer.rb | 42 +++++++------------ test/models/family/syncer_test.rb | 70 +++++++++++++++++-------------- 2 files changed, 53 insertions(+), 59 deletions(-) diff --git a/app/models/family/syncer.rb b/app/models/family/syncer.rb index 66721a1a2..9952e2d55 100644 --- a/app/models/family/syncer.rb +++ b/app/models/family/syncer.rb @@ -1,29 +1,6 @@ class Family::Syncer attr_reader :family - # Registry of item association names that participate in family sync. - # Each model must: - # 1. Include Syncable - # 2. Define a `syncable` scope (items ready for auto-sync) - # - # To add a new provider: add its association name here. - # The model handles its own "ready to sync" logic via the syncable scope. - SYNCABLE_ITEM_ASSOCIATIONS = %i[ - plaid_items - simplefin_items - lunchflow_items - akahu_items - enable_banking_items - indexa_capital_items - coinbase_items - coinstats_items - mercury_items - brex_items - binance_items - snaptrade_items - sophtron_items - ].freeze - def initialize(family) @family = family end @@ -49,14 +26,25 @@ class Family::Syncer private - # Collects all syncable items from registered providers + manual accounts. - # Each provider model defines its own `syncable` scope that encapsulates - # the "ready to sync" business logic (active, configured, etc.) + # Collect all syncable provider items via reflection so new `*_items` + # integrations participate in nightly family sync as soon as they include + # Syncable and expose a `syncable` scope. def child_syncables - provider_items = SYNCABLE_ITEM_ASSOCIATIONS.flat_map do |association| + provider_items = syncable_item_associations.flat_map do |association| family.public_send(association).syncable end provider_items + family.accounts.manual end + + def syncable_item_associations + Family.reflect_on_all_associations(:has_many).filter_map do |association| + next unless association.name.to_s.end_with?("_items") + next unless association.klass.included_modules.include?(Syncable) + + association.name + rescue NameError + nil + end + end end diff --git a/test/models/family/syncer_test.rb b/test/models/family/syncer_test.rb index beb8c7645..14fac1c04 100644 --- a/test/models/family/syncer_test.rb +++ b/test/models/family/syncer_test.rb @@ -14,11 +14,6 @@ class Family::SyncerTest < ActiveSupport::TestCase ) manual_accounts_count = @family.accounts.manual.count - plaid_items_count = @family.plaid_items.syncable.count - brex_items_count = @family.brex_items.syncable.count - akahu_items_count = @family.akahu_items.syncable.count - binance_items_count = @family.binance_items.syncable.count - syncer = Family::Syncer.new(@family) Account.any_instance @@ -26,31 +21,37 @@ class Family::SyncerTest < ActiveSupport::TestCase .with(parent_sync: family_sync, window_start_date: nil, window_end_date: nil) .times(manual_accounts_count) - PlaidItem.any_instance - .expects(:sync_later) - .with(parent_sync: family_sync, window_start_date: nil, window_end_date: nil) - .times(plaid_items_count) - - BrexItem.any_instance - .expects(:sync_later) - .with(parent_sync: family_sync, window_start_date: nil, window_end_date: nil) - .times(brex_items_count) - - AkahuItem.any_instance - .expects(:sync_later) - .with(parent_sync: family_sync, window_start_date: nil, window_end_date: nil) - .times(akahu_items_count) - - BinanceItem.any_instance - .expects(:sync_later) - .with(parent_sync: family_sync, window_start_date: nil, window_end_date: nil) - .times(binance_items_count) + syncable_item_associations.each do |association| + association.klass.any_instance + .expects(:sync_later) + .with(parent_sync: family_sync, window_start_date: nil, window_end_date: nil) + .times(@family.public_send(association.name).syncable.count) + end syncer.perform_sync(family_sync) assert_equal "completed", family_sync.reload.status end + test "syncs ibkr items through reflective provider discovery" do + family_sync = syncs(:family) + syncer = Family::Syncer.new(@family) + + assert_includes syncable_item_associations.map(&:name), :ibkr_items + + Account.any_instance.stubs(:sync_later) + syncable_item_associations.reject { |association| association.name == :ibkr_items }.each do |association| + association.klass.any_instance.stubs(:sync_later) + end + + IbkrItem.any_instance + .expects(:sync_later) + .with(parent_sync: family_sync, window_start_date: nil, window_end_date: nil) + .times(@family.ibkr_items.syncable.count) + + syncer.perform_sync(family_sync) + end + test "only applies active rules during sync" do family_sync = syncs(:family) @@ -79,16 +80,21 @@ class Family::SyncerTest < ActiveSupport::TestCase # Mock the account and plaid item syncs to avoid side effects Account.any_instance.stubs(:sync_later) - PlaidItem.any_instance.stubs(:sync_later) - SimplefinItem.any_instance.stubs(:sync_later) - LunchflowItem.any_instance.stubs(:sync_later) - EnableBankingItem.any_instance.stubs(:sync_later) - SophtronItem.any_instance.stubs(:sync_later) - BrexItem.any_instance.stubs(:sync_later) - AkahuItem.any_instance.stubs(:sync_later) - BinanceItem.any_instance.stubs(:sync_later) + syncable_item_associations.each do |association| + association.klass.any_instance.stubs(:sync_later) + end syncer.perform_sync(family_sync) syncer.perform_post_sync end + + private + def syncable_item_associations + Family.reflect_on_all_associations(:has_many).select do |association| + association.name.to_s.end_with?("_items") && + association.klass.included_modules.include?(Syncable) + rescue NameError + false + end + end end From 2d3d9744664b8dffa1e95d5ac69eba2ee3316fca Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Mon, 15 Jun 2026 20:11:36 +0200 Subject: [PATCH 02/11] fix(ds): canonical separators and destructive tokens in usage/rules tables (#2332) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LLM usage table (Settings → AI usage) and the rules recent-runs table used hardcoded color classes instead of design-system tokens: - `divide-gray-100` separators — a fixed light gray with no dark-theme variant, so the row dividers render wrong in dark mode. - Raw reds for failed rows (`bg-red-50`/`bg-red-950`, `text-red-500/600`). Swap to the canonical tokens used by every other table (settings/debugs, admin/users, …): - divide-gray-100 -> divide-alpha-black-200 theme-dark:divide-alpha-white-200 - bg-red-50 / bg-red-950/30 -> bg-red-tint-5 / bg-red-tint-10 - text-red-* -> text-destructive (via the icon helper's color: param) Token-only; no structural or behavior change. Co-authored-by: Guillem Arias --- app/views/rules/index.html.erb | 6 +++--- app/views/settings/llm_usages/show.html.erb | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/views/rules/index.html.erb b/app/views/rules/index.html.erb index 57fc04e12..847c04204 100644 --- a/app/views/rules/index.html.erb +++ b/app/views/rules/index.html.erb @@ -133,9 +133,9 @@ - + <% @recent_runs.each do |run| %> - "> + "> <%= run.executed_at.strftime("%b %d, %Y %I:%M %p") %> @@ -155,7 +155,7 @@ <% end %> <% if run.failed? && run.error_message.present? %>
- <%= icon("info", size: "sm", class: "text-red-500") %> + <%= icon("info", size: "sm", color: "destructive") %>
<% end %> diff --git a/app/views/settings/llm_usages/show.html.erb b/app/views/settings/llm_usages/show.html.erb index 50d05f649..681aa2341 100644 --- a/app/views/settings/llm_usages/show.html.erb +++ b/app/views/settings/llm_usages/show.html.erb @@ -115,9 +115,9 @@ <%= t(".col_cost") %> - + <% @llm_usages.each do |usage| %> - "> + "> <%= usage.created_at.strftime("%b %d, %Y %I:%M %p") %> @@ -131,8 +131,8 @@ <% if usage.failed? %>
- <%= icon "alert-circle", class: "w-4 h-4 text-red-600 theme-dark:text-red-400" %> - <%= t(".failed") %> + <%= icon "alert-circle", size: "sm", color: "destructive" %> + <%= t(".failed") %>
- + <% end %>
diff --git a/test/components/DS/disclosure_test.rb b/test/components/DS/disclosure_test.rb new file mode 100644 index 000000000..b8154a49f --- /dev/null +++ b/test/components/DS/disclosure_test.rb @@ -0,0 +1,28 @@ +require "test_helper" + +class DS::DisclosureTest < ViewComponent::TestCase + test "body wrapper defaults to an mt-2 margin" do + render_inline(DS::Disclosure.new(title: "More", open: true)) { "body text" } + + assert_selector "details > div.mt-2", text: "body text" + end + + test "body_class: nil drops the body margin wrapper" do + render_inline(DS::Disclosure.new(title: "More", open: true, body_class: nil)) { "body text" } + + assert_no_selector "details > div.mt-2" + assert_selector "details > div", text: "body text" + end + + test "forwards data attributes and a summary_class override" do + render_inline(DS::Disclosure.new( + summary_class: "custom-summary", + data: { controller: "color-icon-picker", action: "mousedown->color-icon-picker#handleOutsideClick" } + )) do |disclosure| + disclosure.with_summary_content { "trigger" } + end + + assert_selector "details[data-controller='color-icon-picker']" + assert_selector "summary.custom-summary", text: "trigger" + end +end From 43b12311e6f4daea7c282212080394bec21e4bd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Orange=F0=9F=8D=8A?= Date: Tue, 16 Jun 2026 02:24:14 +0800 Subject: [PATCH 04/11] chore(i18n): complete zh-CN locale coverage (#2283) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(i18n): complete zh-CN locale coverage Translate all 903 missing zh-CN keys, following the per-module locale file structure mirroring the en baseline: - New locale files: goals, goal_pledges, akahu_items (views), and goal, goal_pledge, merchant_import (models) - Fill remaining gaps across imports, settings/hostings, components, transfers, transactions, categories, and 30+ other modules - Fix interpolation mismatches against the en baseline (%{moniker}, %{product_name}, %{action_verb}, %{count} in pluralized one-keys) - Quote the "on" key in valuations so it parses as a string key instead of a YAML boolean, matching en - Add zh-CN date formats month_year / short_month_year used by reports and budgets Brand names, protocol names, and product-specific terms (Plaid, QIF, YNAB, PSD2, HSA, API keys) are intentionally kept in English. Verification: - i18n-tasks missing -l zh-CN: 0 (the 4 remaining entries are i18n-tasks scanner artifacts also reported for the en baseline) - i18n-tasks check-consistent-interpolations: clean for zh-CN - bin/rails test: 4875 runs, 0 failures - bin/rubocop: no offenses * chore(i18n): address review feedback on zh-CN terminology - Unify Lunch Flow branding (two words) and use API 密钥 consistently in lunchflow_items - Use API 密钥 instead of API Key in settings/api_keys create/destroy messages, matching the rest of the file - Use 循环 for recurring transfers, matching the recurring wording used by transactions * fix(i18n): keep zh-CN user family labels literal to avoid moniker interpolation error Unlike Account, User has no human_attribute_name override injecting the moniker option, so %{moniker} in user attribute labels raises I18n::MissingInterpolationArgument whenever errors.full_messages formats a family/family_id validation error. Keep the labels literal (家庭), consistent with de/es/fr/nb/nl/pt-BR/ ro/tr/zh-TW which also use literal words here. Note: en/ca/hu/pl/vi carry the same latent issue with %{moniker} in user attribute labels; left untouched as fixing the en baseline is out of scope for this locale PR. * fix(i18n): 补齐运行时作用域翻译键 * fix(i18n): 移除失效目标切换文案 --- config/locales/defaults/zh-CN.yml | 6 +- config/locales/models/account/zh-CN.yml | 17 +- config/locales/models/goal/zh-CN.yml | 25 ++ config/locales/models/goal_pledge/zh-CN.yml | 20 ++ config/locales/models/import/zh-CN.yml | 5 + .../locales/models/merchant_import/zh-CN.yml | 8 + config/locales/models/transfer/zh-CN.yml | 4 + config/locales/views/akahu_items/zh-CN.yml | 127 +++++++++ config/locales/views/binance_items/zh-CN.yml | 1 + config/locales/views/brex_items/zh-CN.yml | 36 +++ config/locales/views/budgets/zh-CN.yml | 1 + config/locales/views/categories/zh-CN.yml | 17 ++ config/locales/views/components/zh-CN.yml | 86 ++++++ config/locales/views/credit_cards/zh-CN.yml | 1 + config/locales/views/cryptos/zh-CN.yml | 13 + config/locales/views/depositories/zh-CN.yml | 16 ++ .../views/enable_banking_items/zh-CN.yml | 1 + config/locales/views/entries/zh-CN.yml | 9 + config/locales/views/family_exports/zh-CN.yml | 12 + config/locales/views/goal_pledges/zh-CN.yml | 20 ++ config/locales/views/goals/zh-CN.yml | 268 ++++++++++++++++++ .../views/impersonation_sessions/zh-CN.yml | 10 + config/locales/views/imports/en.yml | 2 + config/locales/views/imports/zh-CN.yml | 160 +++++++++++ .../locales/views/invitation_mailer/zh-CN.yml | 2 +- config/locales/views/invitations/zh-CN.yml | 1 + config/locales/views/layout/zh-CN.yml | 1 + config/locales/views/loans/zh-CN.yml | 4 + .../locales/views/lunchflow_items/zh-CN.yml | 4 + config/locales/views/merchants/zh-CN.yml | 1 + config/locales/views/oidc_accounts/zh-CN.yml | 11 +- config/locales/views/pages/zh-CN.yml | 3 + .../locales/views/password_resets/zh-CN.yml | 2 + config/locales/views/plaid_items/zh-CN.yml | 9 + config/locales/views/properties/zh-CN.yml | 2 + config/locales/views/reports/zh-CN.yml | 2 + .../locales/views/settings/api_keys/zh-CN.yml | 7 + config/locales/views/settings/hostings/en.yml | 2 + .../locales/views/settings/hostings/zh-CN.yml | 145 ++++++++++ .../views/settings/securities/zh-CN.yml | 21 ++ config/locales/views/settings/zh-CN.yml | 9 + config/locales/views/shared/zh-CN.yml | 4 + .../locales/views/simplefin_items/zh-CN.yml | 2 +- config/locales/views/subscriptions/zh-CN.yml | 12 +- config/locales/views/tag/deletions/zh-CN.yml | 1 + config/locales/views/tags/zh-CN.yml | 3 + config/locales/views/transactions/zh-CN.yml | 21 ++ config/locales/views/transfers/zh-CN.yml | 17 ++ config/locales/views/users/zh-CN.yml | 12 + config/locales/views/valuations/zh-CN.yml | 6 +- config/locales/views/vehicles/zh-CN.yml | 1 + 51 files changed, 1161 insertions(+), 9 deletions(-) create mode 100644 config/locales/models/goal/zh-CN.yml create mode 100644 config/locales/models/goal_pledge/zh-CN.yml create mode 100644 config/locales/models/merchant_import/zh-CN.yml create mode 100644 config/locales/views/akahu_items/zh-CN.yml create mode 100644 config/locales/views/goal_pledges/zh-CN.yml create mode 100644 config/locales/views/goals/zh-CN.yml diff --git a/config/locales/defaults/zh-CN.yml b/config/locales/defaults/zh-CN.yml index 370d0833a..fcdc2591c 100644 --- a/config/locales/defaults/zh-CN.yml +++ b/config/locales/defaults/zh-CN.yml @@ -48,7 +48,9 @@ zh-CN: formats: default: "%Y-%m-%d" long: "%Y年%m月%d日" + month_year: "%Y年%m月" short: "%m月%d日" + short_month_year: "%Y年%m月" month_names: - - 一月 @@ -83,7 +85,7 @@ zh-CN: other: 接近%{count}年 half_a_minute: 半分钟 less_than_x_minutes: - one: 不到%{count}分钟 + one: 不到 1 分钟 other: 不到%{count}分钟 less_than_x_seconds: one: 不到%{count}秒 @@ -223,3 +225,5 @@ zh-CN: long: "%Y年%m月%d日 %H:%M" short: "%m月%d日 %H:%M" pm: 下午 + common: + close: 关闭 diff --git a/config/locales/models/account/zh-CN.yml b/config/locales/models/account/zh-CN.yml index 97b168276..8e2407ff8 100644 --- a/config/locales/models/account/zh-CN.yml +++ b/config/locales/models/account/zh-CN.yml @@ -5,8 +5,8 @@ zh-CN: account: balance: 余额 currency: 币种 - family: 家庭 - family_id: 家庭 + family: "%{moniker}" + family_id: "%{moniker}" name: 名称 subtype: 子类型 models: @@ -19,3 +19,16 @@ zh-CN: account/other_liability: 其他负债 account/property: 房产/房地产 account/vehicle: 车辆 + account_order: + balance_asc: + label: 余额(从低到高) + label_short: 余额 ↑ + balance_desc: + label: 余额(从高到低) + label_short: 余额 ↓ + name_asc: + label: 名称(A-Z) + label_short: 名称 ↑ + name_desc: + label: 名称(Z-A) + label_short: 名称 ↓ diff --git a/config/locales/models/goal/zh-CN.yml b/config/locales/models/goal/zh-CN.yml new file mode 100644 index 000000000..3ce9dff54 --- /dev/null +++ b/config/locales/models/goal/zh-CN.yml @@ -0,0 +1,25 @@ +--- +zh-CN: + activerecord: + attributes: + goal: + name: 名称 + target_amount: 目标金额 + currency: 货币 + target_date: 目标日期 + color: 颜色 + notes: 备注 + state: 状态 + linked_accounts: 关联账户 + errors: + models: + goal: + attributes: + base: + at_least_one_linked_account_required: 请至少选择一个存款账户为此目标提供资金。 + linked_accounts: + must_be_depository: 所有关联账户都必须是存款账户(支票、储蓄、HSA、CD、货币市场账户)。 + currency_mismatch: 所有关联账户必须使用同一货币。 + must_belong_to_family: 关联账户必须属于与目标相同的家庭。 + currency: + locked_after_linked: 目标关联账户后不能更改货币。 diff --git a/config/locales/models/goal_pledge/zh-CN.yml b/config/locales/models/goal_pledge/zh-CN.yml new file mode 100644 index 000000000..902ae9872 --- /dev/null +++ b/config/locales/models/goal_pledge/zh-CN.yml @@ -0,0 +1,20 @@ +--- +zh-CN: + activerecord: + attributes: + goal_pledge: + amount: 金额 + currency: 货币 + account: 账户 + kind: 类型 + status: 状态 + expires_at: 过期时间 + errors: + models: + goal_pledge: + attributes: + account: + must_be_linked_to_goal: 请选择该目标的一个关联账户。 + currency: + must_match_goal: 承诺货币必须与目标货币一致。 + duplicate_open_pledge: 此账户已有相同金额的未完成承诺。记录新的承诺前,请先取消或延长现有承诺。 diff --git a/config/locales/models/import/zh-CN.yml b/config/locales/models/import/zh-CN.yml index 5be74fe23..7561d6f1c 100644 --- a/config/locales/models/import/zh-CN.yml +++ b/config/locales/models/import/zh-CN.yml @@ -5,9 +5,14 @@ zh-CN: import: currency: 货币 number_format: 数字格式 + col_sep: 列分隔符 + col_seps: + comma: 逗号(,) + semicolon: 分号(;) errors: models: import: attributes: raw_file_str: invalid_csv_format: 格式无效,不是合法的CSV格式 + duplicate_headers: CSV 表头规范化后出现重复列:%{columns} diff --git a/config/locales/models/merchant_import/zh-CN.yml b/config/locales/models/merchant_import/zh-CN.yml new file mode 100644 index 000000000..5129e4a43 --- /dev/null +++ b/config/locales/models/merchant_import/zh-CN.yml @@ -0,0 +1,8 @@ +--- +zh-CN: + activerecord: + errors: + models: + merchant_import: + missing_columns: 缺少必填列:%{columns} + duplicate_columns: 规范化后列名重复:%{columns} diff --git a/config/locales/models/transfer/zh-CN.yml b/config/locales/models/transfer/zh-CN.yml index b1f6113dd..f1dfe610d 100644 --- a/config/locales/models/transfer/zh-CN.yml +++ b/config/locales/models/transfer/zh-CN.yml @@ -13,6 +13,10 @@ zh-CN: must_have_opposite_amounts: 转账交易的金额必须互为相反数 must_have_single_currency: 转账必须使用单一货币 outflow_cannot_be_in_multiple_transfers: 出账交易不能参与多次转账 + different_accounts: 必须来自不同账户 + same_family: 必须来自同一家庭 + opposite_amounts: 金额必须方向相反 + within_days: 必须在 %{count} 天内 transfer: name: 转账至 %{to_account} payment_name: 付款至 %{to_account} diff --git a/config/locales/views/akahu_items/zh-CN.yml b/config/locales/views/akahu_items/zh-CN.yml new file mode 100644 index 000000000..26776b065 --- /dev/null +++ b/config/locales/views/akahu_items/zh-CN.yml @@ -0,0 +1,127 @@ +--- +zh-CN: + providers: + akahu: + name: Akahu + description: 通过 Akahu 连接新西兰银行账户 + family: + akahu: + create_akahu_item: + default_name: Akahu 连接 + akahu_account: + fallback: Akahu 账户 + akahu_entry: + notes: + reference: 参考号 + particulars: 明细 + code: 代码 + other_account: 其他账户 + akahu_item: + errors: + account_processing_failed: 无法同步 Akahu 账户 + account_sync_schedule_failed: 无法安排 Akahu 账户同步 + pending_transactions_failed: 无法获取 Akahu 待处理交易 + transactions_failed: 无法获取 Akahu 交易 + sync_failed: 无法同步 Akahu 连接 + sync_status: + no_accounts: 未找到账户 + all_synced: + one: 已同步 1 个账户 + other: 已同步 %{count} 个账户 + partial: 已同步 %{linked} 个,%{unlinked} 个需要设置 + institution_summary: + none: 没有已连接的机构 + one: 1 个机构 + count: + one: 1 个机构 + other: "%{count} 个机构" + akahu_items: + provider_panel: + default_connection_name: Akahu 连接 + add_connection: 添加 Akahu 连接 + update_connection: 更新 连接 + connection_name_label: 连接名称 + connection_name_placeholder: 主 Akahu 连接 + app_token_label: App Token + app_token_placeholder: 粘贴 Akahu App Token + keep_app_token_placeholder: 留空以保留现有 App Token + user_token_label: 用户 令牌 + user_token_placeholder: 粘贴 Akahu User Token + keep_user_token_placeholder: 留空以保留现有 User Token + setup_accounts: 设置账户 + syncing: 正在同步... + sync: 同步 + disconnect: 断开连接 + disconnect_confirm: 确定要断开 %{name}? + create: + success: Akahu 连接已保存。 + update: + success: Akahu 连接已更新。 + destroy: + success: 已安排删除 Akahu 连接。 + unlink_failed: 无法断开 Akahu 连接 + select_accounts: + title: 关联 Akahu 账户 + description: 选择要添加的 Akahu 账户。 + no_accounts_found: 未找到未关联的 Akahu 账户。 + no_credentials_configured: 请先在提供商设置中配置 Akahu。 + cancel: 取消 + link_accounts: 链接 账户 + link_accounts: + success: + one: 已关联 1 个 Akahu 账户。 + other: 已关联 %{count} 个 Akahu 账户。 + no_accounts_selected: 请选择至少一个账户。 + no_credentials_configured: 请先在提供商设置中配置 Akahu。 + unsupported_account_type: Akahu 不支持该账户类型。 + link_failed: 没有账户被关联。 + select_existing_account: + title: 将 Akahu 账户关联到 %{account_name} + description: 选择一个未关联的 Akahu 账户连接到此账户。 + no_accounts_found: 未找到未关联的 Akahu 账户。 + no_credentials_configured: 请先在提供商设置中配置 Akahu。 + account_already_linked: 此账户已关联到提供商。 + cancel: 取消 + link_account: 链接 账户 + link_existing_account: + success: 已将 Akahu 账户关联到 %{account_name}。 + account_already_linked: 此账户已关联到提供商。 + akahu_account_already_linked: 此 Akahu 账户已关联。 + setup_accounts: + title: 设置 Akahu 账户 + subtitle: 选择每个 Akahu 账户在 Sure 中的显示方式。 + no_credentials: 请先配置 Akahu 凭据。 + api_error: 无法获取 Akahu 账户。 + fetch_failed: 无法获取账户 + no_accounts_to_setup: 没有需要设置的账户 + all_accounts_linked: 所有 Akahu 账户都已关联。 + choose_account_type: 选择账户类型 + choose_account_type_description: 跳过不想跟踪的账户。 + account_type_label: 账户类型 + account_types: + skip: 跳过 + depository: 现金 + credit_card: 信用卡 + investment: 投资 + loan: 贷款 + create_accounts: 创建账户 + cancel: 取消 + complete_account_setup: + success: + one: 已创建 1 个 Akahu 账户。 + other: 已创建 %{count} 个 Akahu 账户。 + all_skipped: 没有创建 Akahu 账户。 + no_accounts: 没有选择 Akahu 账户。 + creation_failed: 无法创建 Akahu 账户。 + akahu_item: + deletion_in_progress: 正在删除 + syncing: 正在同步 + error: 错误 + status_with_summary: "%{timestamp} 前已同步 · %{summary}" + status_never: 从未同步 + delete: 删除 + setup_needed: 需要设置账户 + setup_description: 已关联 %{linked}/%{total} 个账户。 + setup_action: 设置账户 + no_accounts_title: 尚未导入账户 + no_accounts_description: 获取 Akahu 账户并选择要关联的账户。 diff --git a/config/locales/views/binance_items/zh-CN.yml b/config/locales/views/binance_items/zh-CN.yml index 9e62db4bc..702449929 100644 --- a/config/locales/views/binance_items/zh-CN.yml +++ b/config/locales/views/binance_items/zh-CN.yml @@ -20,6 +20,7 @@ zh-CN: import_selected: 导入所选 cancel: 取消 creating: 正在导入... + historical_import: 历史导入 complete_account_setup: success: one: 已导入 %{count} 个账户 diff --git a/config/locales/views/brex_items/zh-CN.yml b/config/locales/views/brex_items/zh-CN.yml index fa8ec0a6c..7cd3752aa 100644 --- a/config/locales/views/brex_items/zh-CN.yml +++ b/config/locales/views/brex_items/zh-CN.yml @@ -239,3 +239,39 @@ zh-CN: sync_start_date_help: 选择您希望向前同步交易历史的时间范围。最多可用 3 年历史记录。 sync_start_date_label: "从以下日期开始同步交易:" title: 设置您的 Brex 账户 + complete_account_setup: + all_skipped: 所有账户都已跳过,未创建账户。 + creation_failed: 创建账户失败:%{error} + creation_failed_count: "%{count} 个账户创建失败。" + no_accounts: 没有需要设置的账户。 + partial_skipped: 已成功创建 %{created_count} 个账户;已跳过 %{skipped_count} 个账户。 + partial_success: 已成功创建 %{created_count} 个账户,但 %{failed_count} 个账户失败。 + success: 已成功创建 %{count} 个账户。 + unexpected_error: 出现意外错误。 + sync: + success: 同步已开始 + syncer: + account_processing_failed: + one: "%{count} 个 Brex 账户处理失败。" + other: "%{count} 个 Brex 账户处理失败。" + account_sync_failed: + one: "%{count} 个 Brex 账户同步无法安排。" + other: "%{count} 个 Brex 账户同步无法安排。" + accounts_need_setup: + one: "%{count} 个账户需要设置..." + other: "%{count} 个账户需要设置..." + accounts_failed: + one: "%{count} 个 Brex 账户导入失败。" + other: "%{count} 个 Brex 账户导入失败。" + calculating_balances: 正在计算余额... + checking_account_configuration: 正在检查账户配置... + credentials_invalid: Brex 凭据无效。 + failed: 同步失败。请重试或联系支持。 + import_failed: Brex 导入失败。 + importing_accounts: 正在从 Brex 导入账户... + processing_transactions: 正在处理交易... + transactions_failed: + one: "%{count} 个 Brex 账户存在交易导入失败。" + other: "%{count} 个 Brex 账户存在交易导入失败。" + update: + success: Brex 连接已更新 diff --git a/config/locales/views/budgets/zh-CN.yml b/config/locales/views/budgets/zh-CN.yml index e32bfb743..142185174 100644 --- a/config/locales/views/budgets/zh-CN.yml +++ b/config/locales/views/budgets/zh-CN.yml @@ -50,6 +50,7 @@ zh-CN: all: 全部 on_track: 正常 over_budget: 超支 + aria_label: 筛选预算分类 tabs: actual: 实际 budgeted: 预算 diff --git a/config/locales/views/categories/zh-CN.yml b/config/locales/views/categories/zh-CN.yml index 785587249..0e323090e 100644 --- a/config/locales/views/categories/zh-CN.yml +++ b/config/locales/views/categories/zh-CN.yml @@ -31,6 +31,7 @@ zh-CN: delete_all: 全部删除 empty: 未找到分类 new: 新建分类 + merge: 合并分类 menu: loading: 正在加载... new: @@ -41,6 +42,22 @@ zh-CN: transfer: 转账 payment: 付款 trade: 交易 + merge: + title: 合并分类 + description: 选择要合并的分类,并指定保留的目标分类。 + target_label: 合并到(目标) + select_target: 选择目标分类... + sources_label: 要合并的分类 + sources_hint: 这些分类会被合并到目标分类中。 + submit: 合并所选分类 + perform_merge: + success: + one: 已成功合并 %{count} 个分类。 + other: 已成功合并 %{count} 个分类。 + no_categories_selected: 请选择要合并的分类。 + target_not_found: 找不到目标分类。 + invalid_categories: 选择的分类无效。 + target_selected_as_source: 目标分类不能同时作为来源分类。 category: dropdowns: show: diff --git a/config/locales/views/components/zh-CN.yml b/config/locales/views/components/zh-CN.yml index 4f8f2189b..b92eaf475 100644 --- a/config/locales/views/components/zh-CN.yml +++ b/config/locales/views/components/zh-CN.yml @@ -160,3 +160,89 @@ zh-CN: data_warnings: "数据警告:%{count}" notices: "通知:%{count}" view_data_quality: 查看数据质量详情 + period_picker: + aria_label: 时间范围:%{period} + ds: + alert: + variants: + info: 信息 + success: 成功 + warning: 警告 + error: 错误 + destructive: 错误 + pill: + aria_label: "%{label}" + default_label: 预览 + dialog: + close: 关闭 + popover: + avatar_default_label: 打开菜单 + tooltip: + trigger_label: 更多信息 + link: + opens_in_new_tab: "(在新标签页打开)" + provider_sync_summary: + title: 同步摘要 + last_sync: 上次同步:%{time_ago} 前 + accounts: + title: 账户 + total: 总计:%{count} + linked: 已关联:%{count} + unlinked: 未关联:%{count} + institutions: 机构:%{count} + transactions: + title: 交易 + seen: 已看到:%{count} + imported: 已导入:%{count} + updated: 已更新:%{count} + skipped: 已跳过:%{count} + fetching: 正在从券商获取... + protected: + one: "%{count} 条记录受保护(未覆盖)" + other: "%{count} 条记录受保护(未覆盖)" + view_protected: 查看受保护记录 + skip_reasons: + excluded: 已排除 + user_modified: 用户已修改 + import_locked: CSV 导入 + protected: 受保护 + holdings: + title: 持仓 + found: 已找到:%{count} + processed: 已处理:%{count} + trades: + title: 交易 + imported: 已导入:%{count} + skipped: 已跳过:%{count} + fetching: 正在从券商获取活动... + health: + title: 健康状况 + view_error_details: 查看错误详情 + rate_limited: "%{time_ago} 前触发限流" + recently: 最近 + errors: 错误:%{count} + pending_reconciled: + one: 已核对 %{count} 笔重复待处理交易 + other: 已核对 %{count} 笔重复待处理交易 + view_reconciled: 查看已核对交易 + duplicate_suggestions: + one: "%{count} 条可能重复记录需要查看" + other: "%{count} 条可能重复记录需要查看" + view_duplicate_suggestions: 查看建议的重复项 + stale_pending: + one: "%{count} 笔过期待处理交易(已从预算中排除)" + other: "%{count} 笔过期待处理交易(已从预算中排除)" + view_stale_pending: 查看受影响账户 + stale_pending_count: + one: "%{count} 笔交易" + other: "%{count} 笔交易" + stale_unmatched: + one: "%{count} 笔待处理交易需要手动审核" + other: "%{count} 笔待处理交易需要手动审核" + view_stale_unmatched: 查看需要审核的交易 + stale_unmatched_count: + one: "%{count} 笔交易" + other: "%{count} 笔交易" + data_warnings: 数据警告:%{count} + notices: 通知:%{count} + view_data_quality: 查看数据质量详情 diff --git a/config/locales/views/credit_cards/zh-CN.yml b/config/locales/views/credit_cards/zh-CN.yml index 493b85034..4e785d97b 100644 --- a/config/locales/views/credit_cards/zh-CN.yml +++ b/config/locales/views/credit_cards/zh-CN.yml @@ -23,3 +23,4 @@ zh-CN: expiration_date: 到期日期 minimum_payment: 最低还款额 unknown: 未知 + edit_account_details: 编辑账户详情 diff --git a/config/locales/views/cryptos/zh-CN.yml b/config/locales/views/cryptos/zh-CN.yml index 4b19e0d1b..298d38c73 100644 --- a/config/locales/views/cryptos/zh-CN.yml +++ b/config/locales/views/cryptos/zh-CN.yml @@ -5,3 +5,16 @@ zh-CN: edit: 编辑%{account} new: title: 请输入账户余额 + form: + subtype_label: 账户类型 + subtype_prompt: 选择账户类型 + subtype_none: 无 + tax_treatment_label: 税务处理 + tax_treatment_hint: 大多数加密资产属于应税账户。若持有在税收优惠账户中,请选择其他选项。 + subtypes: + wallet: + short: 钱包 + long: 加密钱包 + exchange: + short: 交易所 + long: 加密交易所 diff --git a/config/locales/views/depositories/zh-CN.yml b/config/locales/views/depositories/zh-CN.yml index bf317fa93..4deeccb8d 100644 --- a/config/locales/views/depositories/zh-CN.yml +++ b/config/locales/views/depositories/zh-CN.yml @@ -8,3 +8,19 @@ zh-CN: subtype_prompt: 选择账户类型 new: title: 请输入账户余额 + subtypes: + cd: + long: 定期存单 + short: 定期存单 + checking: + long: 支票账户 + short: 支票账户 + hsa: + long: 健康储蓄账户 + short: HSA + money_market: + long: 货币市场 + short: 货币市场 + savings: + long: 储蓄账户 + short: 储蓄账户 diff --git a/config/locales/views/enable_banking_items/zh-CN.yml b/config/locales/views/enable_banking_items/zh-CN.yml index 7038de650..a44e64a96 100644 --- a/config/locales/views/enable_banking_items/zh-CN.yml +++ b/config/locales/views/enable_banking_items/zh-CN.yml @@ -74,6 +74,7 @@ zh-CN: create_accounts: 创建账户 creating_accounts: 正在创建账户... cancel: 取消 + psd2_savings_notice: 根据 PSD2 规则,部分储蓄账户可能需要额外授权。 new: link_enable_banking_title: 连接 Enable Banking session_expired: 会话已过期 - 需要重新授权 diff --git a/config/locales/views/entries/zh-CN.yml b/config/locales/views/entries/zh-CN.yml index e40ed45a3..633ebb105 100644 --- a/config/locales/views/entries/zh-CN.yml +++ b/config/locales/views/entries/zh-CN.yml @@ -12,3 +12,12 @@ zh-CN: loading: 正在加载记录... update: success: 记录已更新 + unlock: + success: 记录解除锁定成功 + protection: + tooltip: 受同步保护 + title: 受同步保护 + description: 你对此记录的编辑不会被提供商同步覆盖。 + locked_fields_label: 锁定字段: + unlock_button: 允许同步更新 + unlock_confirm: 允许同步更新此记录?下次同步时你的更改可能会被覆盖。 diff --git a/config/locales/views/family_exports/zh-CN.yml b/config/locales/views/family_exports/zh-CN.yml index 1f58f5dd4..b1e5c015e 100644 --- a/config/locales/views/family_exports/zh-CN.yml +++ b/config/locales/views/family_exports/zh-CN.yml @@ -29,3 +29,15 @@ zh-CN: delete: 删除 download: 下载 empty: 暂无导出记录 + new: + dialog_title: 导出你的数据 + dialog_subtitle: 下载此家庭的财务数据副本。 + whats_included: 包含内容: + accounts_and_balances: 所有账户和余额 + transaction_history: 交易历史 + investment_trades: 投资交易 + categories_tags_rules: 分类、标签和规则 + note_label: 说明 + note_description: 导出文件可能包含敏感财务信息,请妥善保管。 + cancel: 取消 + export_data: 导出数据 diff --git a/config/locales/views/goal_pledges/zh-CN.yml b/config/locales/views/goal_pledges/zh-CN.yml new file mode 100644 index 000000000..09f87ff4e --- /dev/null +++ b/config/locales/views/goal_pledges/zh-CN.yml @@ -0,0 +1,20 @@ +--- +zh-CN: + goal_pledges: + new: + helper_transfer: 记录一次已完成的转账,系统会在同步后匹配。 + helper_manual: 记录一次你手动留出的资金。 + amount_label: 金额 + account_label: 存入账户 + submit: 记录承诺 + preview_zero: 当前已存 {current}/{target}。 + preview_nonzero: 将达到 {percent}%,即 {newTotal}/{target}。 + preview_reached: 将达到目标 {target}。 + create: + success: 目标承诺成功 + renew: + success: 承诺已续期。 + not_open: 只有待处理的承诺可以续期。 + destroy: + success: 承诺已取消。 + not_open: 只有待处理的承诺可以取消。 diff --git a/config/locales/views/goals/zh-CN.yml b/config/locales/views/goals/zh-CN.yml new file mode 100644 index 000000000..6f9ab8b29 --- /dev/null +++ b/config/locales/views/goals/zh-CN.yml @@ -0,0 +1,268 @@ +--- +zh-CN: + goals: + color_picker: + trigger_label: 选择颜色和图标 + color_heading: 颜色 + icon_heading: 图标 + poor_contrast: 对比度较低,请选择更深的颜色或 + auto_adjust: 自动调整。 + index: + title: 目标 + subtitle: 为重要目标储蓄。 + new_goal: 新建目标 + empty_filtered: 没有匹配的目标。 + pending_pledges_callout: 你有待确认的承诺。Sure 会在下次同步时确认。 + kpi: + contributed_label: 已投入 · 最近 30 天 + velocity_delta_up: 较前 30 天 ↑ %{percent}%% + velocity_delta_down: 较前 30 天 ↓ %{percent}%% + velocity_delta_flat: 相比前 30 天 + velocity_delta_zero_base: 首个 30 天活动期 + needs_this_month_label: 本月所需 + needs_this_month_sub: + one: 1 个目标进度落后 + other: "%{count} 个目标进度落后" + needs_this_month_zero_sub: 没有进度落后的目标 + on_track_label: 进度正常的目标 + on_track_value: "%{total} 个中 %{on_track} 个" + on_track_sub_parts: + reached: + one: 1 个已达成 + other: "%{count} 个已达成" + behind: + one: 1 个落后 + other: "%{count} 个落后" + no_date: + one: 1 个没有截止日期 + other: "%{count} 个没有截止日期" + paused: + one: 1 个已暂停 + other: "%{count} 个已暂停" + on_track_sub_all_good: 所有进行中的目标进度正常 + on_track_all_caught_up: 全部已赶上进度 + goals_section: + heading: 目标 + subtitle: 为重要目标储蓄。 + ongoing_section: + heading: 目标 + archived_section: + heading: 已归档 + search: + placeholder: 搜索目标… + aria_label: 搜索目标 + empty: 没有匹配的目标。 + empty_with_query: 没有与“%{query}”匹配的目标。 + empty_with_filter: 没有目标匹配此筛选条件。 + empty_with_both: 此筛选条件下没有与“%{query}”匹配的目标。 + clear_search: 清除搜索 + show_all: 显示全部 + chips: + all: 全部 + on_track: 进度正常 + behind: 进度落后 + no_target_date: 开放 + paused: 已暂停 + completed: 已完成 + new: + heading: 新建目标 + subtitle: 为具体目标储蓄。 + edit: + heading: 编辑目标 + save: 保存更改 + create: + success: 目标已创建。 + update: + success: 目标已更新。 + destroy: + success: 目标已删除。 + archive_first: 删除前请先归档该目标。 + pause: + success: 目标已暂停。 + invalid_transition: 目标当前状态不能暂停。 + resume: + success: 目标已恢复。 + invalid_transition: 目标当前状态不能恢复。 + complete: + success: 目标已标记为完成。 + invalid_transition: 目标当前状态不能标记为完成。 + archive: + success: 目标已归档。 + invalid_transition: 目标当前状态不能归档。 + unarchive: + success: 目标已恢复。 + invalid_transition: 目标当前状态不能恢复。 + show: + edit: 编辑 + pause: 暂停 + resume: 恢复 + complete: 标记完成 + archive: 归档 + unarchive: 恢复 + delete: 永久删除 + record_pledge_cta: 记录承诺 + pledge_just_transferred: 记录你完成的转账 + pledge_just_saved: 记录你留出的资金 + funding_accounts_heading: 资金账户 + funding_accounts: + empty: + heading: 尚未关联资金账户 + body: 编辑目标以关联用于储蓄的存款账户。 + notes: 备注 + funding_last_30d: 最近 30 天 + funding_last_90d: 最近 90 天 + status_callout: + behind: 每月多存 %{amount} 以赶上进度 + behind_covered: 待确认承诺可以补足差距 + on_track: 预计在 %{date} 左右达成目标 + no_target_date: 设置目标日期以预测完成时间 + pending_pledge: + title: + zero: 待确认:%{amount} 存入 %{account} · 今天过期 + one: 待确认:%{amount} 存入 %{account} · 剩余 1 天 + other: 待确认:%{amount} 存入 %{account} · 剩余 %{count} 天 + body_transfer: Sure 在下次同步发现匹配存款时会自动确认。 + body_manual: 会在你下次手动编辑余额时确认。 + pledged_at: "%{time_ago} 前承诺" + extend: 延长 7 天 + cancel: 取消 + confirm_cancel_title: 取消此承诺? + confirm_cancel_body: "%{amount} 的承诺将被移除。你可以随时记录新的承诺。" + confirm_cancel_cta: 取消承诺 + header: + target: 目标 %{amount} + target_by: 到 %{date} 达到 %{amount} + target_by_past: 目标 %{amount} · 原定 %{date} 到期 + ring: + saved: 已存 + of: 共 %{target} + to_go: 还差 %{amount} + of_target: 目标进度 + aria_label: 目标已完成 %{percent}%。已存 %{target} 中的 %{amount}。 + projection: + heading: 预测 + legend_saved: 已存 + legend_projection: 预测 + legend_required: 所需 + reached: 你已达到目标,无需预测。 + no_target_date: 尚未设置目标日期。设置后可预测完成时间。 + no_pace: 还没有存入资金。向关联账户存钱后即可开始预测。 + behind: 按当前进度会低于目标。 + on_track_html: 按当前进度,你大约会在 %{date} 达成此目标。 + aria_label: "%{name} 的预测图表" + today_marker: 今天 + tooltip_projected: 预计:%{amount} + tooltip_saved: 已存:%{amount} + tooltip_target_relation: 目标 %{target} 的 %{percent}% + catch_up: + title: 每月多存 %{amount} 以赶上进度 + body: 当前进度 %{avg}/月 · 达成目标需要 %{required}/月。 + adjust_target_cta: 改为调整目标 + confirm_complete_title: 将此目标标记为完成? + confirm_complete_body: 完成后会从进行中的目标列表移除。之后仍可归档或恢复。 + confirm_complete_body_short: 当前进度为 %{progress},已存 %{saved}/%{target}。标记完成会把当前结果记录为达成值,而不是原始目标。要继续吗? + confirm_complete_cta: 标记完成 + confirm_archive_title: 归档此目标? + confirm_archive_body: 归档后的目标会从主列表隐藏。之后可以恢复。 + confirm_archive_cta: 归档 + paused_banner: + title: 此目标已暂停 + body: 恢复它以继续跟踪进度。 + resume_cta: 恢复目标 + archived_banner: + title: 此目标已归档 + body: 恢复后可继续投入,也可以保留为记录。 + restore_cta: 恢复目标 + celebration: + heading: 目标已达成。做得不错。 + body: 目标以 %{saved}/%{target} 关闭。可以保留记录,或现在归档。 + archive_cta: 归档目标 + inactive: + heading_paused: 此目标已暂停 + heading_archived: 此目标已归档 + body: 目前已存 %{target} 中的 %{saved}。 + no_target_date: + heading: 添加目标日期 + body: 设置截止日期以预测完成时间并跟踪所需进度。 + cta: 设置目标日期 + empty: + heading: 还没有存入资金 + body: 向关联账户转入资金。Sure 会在下次同步时识别,或你也可以更新手动账户余额。 + errors: + not_found: 找不到该目标。它可能已被删除。 + states: + active: 进行中 + paused: 已暂停 + completed: 已完成 + archived: 已归档 + status: + on_track: 进度正常 + behind: 进度落后 + reached: 已达成 + completed: 已完成 + no_target_date: 开放 + paused: 已暂停 + archived: 已归档 + empty_state: + heading: 还没有目标 + body: 设定目标,关联储蓄账户,并查看进度累积。 + subtitle: 设定目标并开始储蓄。 + new_goal: 创建第一个目标 + no_depository_accounts: 创建目标前至少需要一个存款账户(支票、储蓄、HSA、CD、货币市场账户)。 + add_account: 添加账户 + goal_card: + no_accounts: 没有关联账户 + n_accounts: "%{first} +%{count}" + left: 剩余 + aria_progress: "%{target} 的 %{percent}%" + accounts: + one: 1 个账户 + other: "%{count} 个账户" + no_target_date: 开放 + completed: 已完成 + past_due: 已逾期 + days_left: + one: 剩余 1 天 + other: 剩余 %{count} 天 + pace_with_target: 平均 %{avg}/月 · 目标 %{target}/月 + pace_no_target: 平均 %{avg}/月 + footer_paused: 已暂停 + footer_archived: 已归档 + footer_reached: 目标已达成 + footer_catch_up: 每月存入 %{amount} 以赶上进度 + footer_no_deadline: 开放 + pending_pledge: 待确认承诺 + pending_count: + one: 1 个待处理 + other: "%{count} 个待处理" + footer_no_pledges: 暂无匹配的承诺 + footer_last_today: 上次承诺今天已匹配 + footer_last_days: + one: 上次承诺 1 天前已匹配 + other: 上次承诺 %{count} 天前已匹配 + form: + create: 创建目标 + save: 保存更改 + suggested_with_date: 在 {accounts} 中每月存入 {monthly} 以按时达成目标。 + suggested_no_date: 设置目标日期以预测完成时间。 + errors: + name_required: 请为目标命名。 + amount_required: 请设置大于 0 的目标。 + accounts_required: 请选择至少一个资金账户。 + fields: + name: 名称 + name_placeholder: 应急基金、购房首付… + target_amount: 目标金额 + target_date: 目标日期 + color: 颜色 + notes: 备注(可选) + notes_placeholder: 给未来自己的提醒… + funding_accounts: 资金账户 + funding_accounts_hint: 此目标余额等于这些账户的余额。 + subtypes: + checking: 支票账户 + savings: 储蓄账户 + hsa: HSA + cd: 定期存单 + money_market: 货币市场 + other: 其他 diff --git a/config/locales/views/impersonation_sessions/zh-CN.yml b/config/locales/views/impersonation_sessions/zh-CN.yml index 661759c58..959ca9005 100644 --- a/config/locales/views/impersonation_sessions/zh-CN.yml +++ b/config/locales/views/impersonation_sessions/zh-CN.yml @@ -13,3 +13,13 @@ zh-CN: success: 已离开会话 reject: success: 请求已拒绝 + super_admin_bar: + super_admin: 超级管理员 + jobs: 后台任务 + impersonating: 正在代入用户 + leave: 离开 + terminate: 终止 + join_a_session: 加入会话 + join: 加入 + uuid_placeholder: UUID + request_impersonation: 请求代入访问 diff --git a/config/locales/views/imports/en.yml b/config/locales/views/imports/en.yml index f974e9a23..1e32b7e28 100644 --- a/config/locales/views/imports/en.yml +++ b/config/locales/views/imports/en.yml @@ -173,6 +173,8 @@ en: existing tags. You can also add new tags or leave them uncategorized. tag_mapping_title: Assign your tags uploads: + handle_qif_upload: + qif_uploaded: "QIF file uploaded successfully." update: qif_uploaded: "QIF file uploaded successfully." show: diff --git a/config/locales/views/imports/zh-CN.yml b/config/locales/views/imports/zh-CN.yml index 21be8ebf1..f87646518 100644 --- a/config/locales/views/imports/zh-CN.yml +++ b/config/locales/views/imports/zh-CN.yml @@ -130,6 +130,10 @@ zh-CN: show: description: 选择与 CSV 中每个字段对应的列。 title: 配置您的导入 + merchant_import: + button_label: 继续 + description: 导入商户列表,用于补全交易商户信息。 + instructions: 上传包含商户名称、网站和颜色等字段的 CSV 文件。 confirms: sure_import: title: 确认您的导入 @@ -157,6 +161,8 @@ zh-CN: tag_mapping_description: 将导入文件中的所有标签分配到 %{product_name} 中现有的标签。您也可以添加新标签,或将其保留为未分类。 tag_mapping_title: 分配您的标签 uploads: + handle_qif_upload: + qif_uploaded: QIF 文件上传成功。 update: qif_uploaded: QIF 文件上传成功。 show: @@ -236,6 +242,8 @@ zh-CN: category_parent: 父分类 category_color: 颜色 category_icon: Lucide 图标 + merchant_color: 颜色 + merchant_website: 网站 URL update: account_saved: 账户已保存。 invalid_account: 未找到账户。 @@ -267,6 +275,19 @@ zh-CN: confirm_revert: 这将删除已导入的交易记录,但您仍然可以随时查看和重新导入您的数据。 delete: 删除 view: 查看 + type_labels: + transaction_import: 交易 + trade_import: 投资交易 + account_import: 账户 + mint_import: Mint + actual_import: Actual + qif_import: QIF + category_import: 分类 + rule_import: 规则 + merchant_import: 商户 + pdf_import: PDF + document_import: 文档 + sure_import: Sure empty: 暂无导入记录。 new: description: 您可以通过 CSV 手动导入多种类型数据,或使用我们的导入模板(如 Mint 格式)。 @@ -279,6 +300,18 @@ zh-CN: resume: 继续 %{type} sources: 数据来源 title: 新建 CSV 导入 + tab_financial_tools: 财务工具和文件 + tab_raw_data: 原始数据 + coming_soon: 即将推出 + import_ynab: 从 YNAB 导入 + import_merchants: 导入商户 + import_actual: 从 Actual Budget 导入 + import_qif: 从 Quicken(QIF)导入 + import_sure: 从 Sure 导入 + import_sure_description: 导入 Sure 导出的数据文件。 + import_file: 导入文档 + import_file_description: 上传账单、CSV 或其他财务文档。 + requires_account: 导入前需要先创建账户。 ready: description: 以下是发布导入后将添加到您账户的新项目摘要。 title: 确认导入数据 @@ -287,3 +320,130 @@ zh-CN: empty_summary: 在此文件中未找到可导入的记录。文件可能为空,或各行不符合预期的导出格式(每行应为包含「type」和「data」键的 JSON 对象,且类型须为本导入支持的类型)。 publish_import: 发布导入 back_to_imports: 返回导入列表 + apply_template: + template_applied: 模板已应用。 + no_template_found: 找不到可用模板。 + destroy: + deleted: 导入已删除。 + failure: + title: 导入 失败 + description: 导入过程中出现问题。 + try_again: 重试 + success: + title: 导入成功 + description: 导入成功说明 + back_to_dashboard: 返回仪表盘 + verification: + title: 回读校验 + checked: 已检查 + mismatches: 不匹配 + status: + not_verified: 未校验 + matched: 已匹配 + mismatch: 不匹配 + failed: 失败 + reverted: 已回滚 + importing: + title: 正在导入 + description: 正在处理导入文件,请稍候。 + check_status: 检查状态 + back_to_dashboard: 返回仪表盘 + revert_failure: + title: 回滚导入失败 + description: 请重试 + try_again: 重试 + date_format: + heading: 日期格式 + description: 选择导入文件中日期字段使用的格式。 + preview: 第一条解析日期 + error_title: 日期格式无效 + error_description: 无法按所选格式解析日期,请检查示例。 + type_labels: + transaction_import: 交易导入 + trade_import: 投资交易导入 + account_import: 账户导入 + mint_import: Mint 导入 + actual_import: Actual 导入 + qif_import: QIF 导入 + category_import: 分类导入 + rule_import: 规则导入 + merchant_import: 商户导入 + pdf_import: PDF 导入 + document_import: 文档导入 + sure_import: Sure 导入 + steps: + upload: 上传 + configure: 配置 + clean: 清理 + map: 映射 + confirm: 确认 + select: 选择 + progress: 第 %{step} 步,共 %{total} 步 + empty: + message: 未找到导入记录。 + create: + file_too_large: 文件过大,最大允许 %{max_size}。 + invalid_file_type: 文件类型无效。 + csv_uploaded: CSV 已成功上传。 + ndjson_uploaded: NDJSON 文件已成功上传。 + pdf_too_large: PDF 过大,最大允许 %{max_size}。 + pdf_processing: PDF 正在处理中。 + invalid_pdf: PDF 文件无效。 + duplicate_pdf_unavailable: 暂时无法检查重复 PDF。 + document_too_large: 文档过大,最大允许 %{max_size}。 + invalid_document_file_type: 文档文件类型无效。 + document_uploaded: 文档已成功上传。 + document_upload_failed: 文档上传失败。 + invalid_ndjson_file_type: NDJSON 文件类型无效。 + document_provider_not_configured: 文档处理提供商尚未配置。 + show: + finalize_upload: 完成上传 + finalize_mappings: 完成映射 + errors: + custom_column_requires_inflow: 自定义列需要选择流入金额列。 + document_types: + bank_statement: 银行账单 + credit_card_statement: 信用卡账单 + investment_statement: 投资账单 + financial_document: 财务文档 + contract: 合同 + other: 其他文档 + unknown: 未知文档 + pdf_import: + processing_title: 正在处理 PDF + processing_description: 正在分析文档并提取交易。 + check_status: 检查状态 + back_to_dashboard: 返回仪表盘 + failed_title: 处理失败 + failed_description: 无法处理此文档。请重试或查看日志。 + try_again: 重试 + delete_import: 删除导入 + complete_title: 文档已分析 + complete_description: 文档分析完成,可以继续审核导入结果。 + document_type_label: 文档类型 + source_statement: 来源账单 + summary_label: 摘要 + email_sent_notice: 处理完成后会发送邮件通知。 + back_to_imports: 返回导入 + unknown_state_title: 未知状态 + unknown_state_description: 此导入处于未知状态。请刷新页面或查看日志。 + processing_failed_with_message: "%{message}" + processing_failed_generic: 处理失败:%{error} + ready_for_review_title: 可供审核 + ready_for_review_description: 已提取 %{count} 条交易,请审核后发布。 + transactions_extracted: 已提取交易 + transactions_extracted_count: + one: "%{count} 笔交易" + other: "%{count} 笔交易" + select_account: 导入到账户 + select_account_placeholder: 选择账户... + select_account_hint: 选择这些交易要导入的账户。 + no_accounts: 没有可用账户。 + create_account: 创建账户 + save_account: 保存 + publish_transactions: + one: 发布 %{count} 笔交易 + other: 发布 %{count} 笔交易 + review_transactions: 审核交易 + select_account_to_continue: 请选择账户后继续。 + unknown_document_type: 未知 diff --git a/config/locales/views/invitation_mailer/zh-CN.yml b/config/locales/views/invitation_mailer/zh-CN.yml index 4b3ee1cc4..4b059b415 100644 --- a/config/locales/views/invitation_mailer/zh-CN.yml +++ b/config/locales/views/invitation_mailer/zh-CN.yml @@ -3,6 +3,6 @@ zh-CN: invitation_mailer: invite_email: accept_button: 接受邀请 - body: "%{inviter} 邀请您加入 %{family} 家庭,共同使用 %{product_name}!" + body: "%{inviter} 邀请您加入 %{family} %{moniker},共同使用 %{product_name}!" expiry_notice: 此邀请将在 %{days} 天后过期 greeting: 欢迎使用 %{product_name}! diff --git a/config/locales/views/invitations/zh-CN.yml b/config/locales/views/invitations/zh-CN.yml index 048879292..47f7c7d7d 100644 --- a/config/locales/views/invitations/zh-CN.yml +++ b/config/locales/views/invitations/zh-CN.yml @@ -11,6 +11,7 @@ zh-CN: existing_user_added: 用户已添加到您的家庭中。 failure: 无法发送邀请 success: 邀请已成功发送 + existing_user_has_family_data: 此用户已有家庭数据,无法接受该邀请。 destroy: failure: 移除邀请时出现问题。 not_authorized: 您无权管理邀请。 diff --git a/config/locales/views/layout/zh-CN.yml b/config/locales/views/layout/zh-CN.yml index afcd7112d..a3f43567e 100644 --- a/config/locales/views/layout/zh-CN.yml +++ b/config/locales/views/layout/zh-CN.yml @@ -10,6 +10,7 @@ zh-CN: home: 主页 reports: 报表 transactions: 交易 + goals: 目标 auth: existing_account: 已经有账户? no_account: 新加入 %{product_name}? diff --git a/config/locales/views/loans/zh-CN.yml b/config/locales/views/loans/zh-CN.yml index 0a8353fc7..16f3e6015 100644 --- a/config/locales/views/loans/zh-CN.yml +++ b/config/locales/views/loans/zh-CN.yml @@ -10,6 +10,9 @@ zh-CN: rate_type: 利率类型 term_months: 期限(月) term_months_placeholder: '360' + none: 无 + subtype_prompt: 选择贷款类型 + subtype_none: 无 new: title: 请输入贷款详情 overview: @@ -31,3 +34,4 @@ zh-CN: term: 期限 type: 类型 unknown: 未知 + edit_loan_details: 编辑贷款详情 diff --git a/config/locales/views/lunchflow_items/zh-CN.yml b/config/locales/views/lunchflow_items/zh-CN.yml index ac963800c..8b68df587 100644 --- a/config/locales/views/lunchflow_items/zh-CN.yml +++ b/config/locales/views/lunchflow_items/zh-CN.yml @@ -48,6 +48,7 @@ zh-CN: success: one: 成功连接 %{count} 个账户 other: 成功连接 %{count} 个账户 + no_api_key: 缺少 Lunch Flow API 密钥。 lunchflow_item: accounts_need_setup: 账户需要设置 delete: 删除连接 @@ -75,6 +76,7 @@ zh-CN: no_api_key: 未配置 Lunch Flow API 密钥。请在设置中配置。 no_name_placeholder: (无名称) title: 选择 Lunch Flow 账户 + no_credentials_configured: 请先配置 Lunch Flow 凭据。 select_existing_account: account_already_linked: 此账户已连接到某个提供商 all_accounts_already_linked: 所有 Lunch Flow 账户都已连接 @@ -88,6 +90,7 @@ zh-CN: no_api_key: 未配置 Lunch Flow API 密钥。请在设置中配置。 no_name_placeholder: (无名称) title: 将 %{account_name} 与 Lunch Flow 关联 + no_credentials_configured: 请先配置 Lunch Flow 凭据。 link_existing_account: account_already_linked: 此账户已连接到某个提供商 api_error: API 错误:%{message} @@ -96,6 +99,7 @@ zh-CN: lunchflow_account_not_found: 未找到 Lunch Flow 账户 missing_parameters: 缺少必需参数 success: 已成功将 %{account_name} 与 Lunch Flow 关联 + no_api_key: 缺少 Lunch Flow API 密钥。 setup_accounts: account_type_label: 账户类型: all_accounts_linked: 您的所有 Lunch Flow 账户都已设置完成。 diff --git a/config/locales/views/merchants/zh-CN.yml b/config/locales/views/merchants/zh-CN.yml index b82bd8c85..65b70e51f 100644 --- a/config/locales/views/merchants/zh-CN.yml +++ b/config/locales/views/merchants/zh-CN.yml @@ -34,6 +34,7 @@ zh-CN: merchant: 商户 actions: 操作 source: 来源 + import: 导入商户 family_merchant: edit: 编辑 delete: 删除 diff --git a/config/locales/views/oidc_accounts/zh-CN.yml b/config/locales/views/oidc_accounts/zh-CN.yml index 06668b929..cdb55d010 100644 --- a/config/locales/views/oidc_accounts/zh-CN.yml +++ b/config/locales/views/oidc_accounts/zh-CN.yml @@ -20,6 +20,8 @@ zh-CN: submit_create: 创建账户 account_creation_disabled: 通过单点登录创建账户已禁用。请联系管理员。 cancel: 取消 + no_pending_oidc: 未找到待处理的 OIDC 认证。 + submit_accept_invitation: 接受邀请 new_user: title: 完善您的账户 heading: 创建您的账户 @@ -30,4 +32,11 @@ zh-CN: last_name_label: 姓 last_name_placeholder: 请输入您的姓 submit: 创建账户 - cancel: 取消 \ No newline at end of file + cancel: 取消 + no_pending_oidc: 未找到待处理的 OIDC 认证。 + create_link: + no_pending_oidc: 未找到待处理的 OIDC 认证。 + create_user: + no_pending_oidc: 未找到待处理的 OIDC 认证。 + account_creation_disabled: SSO 账户创建已禁用。请联系管理员。 + account_created: 欢迎!你的账户已创建。 diff --git a/config/locales/views/pages/zh-CN.yml b/config/locales/views/pages/zh-CN.yml index f8d8165b8..4d4a025c9 100644 --- a/config/locales/views/pages/zh-CN.yml +++ b/config/locales/views/pages/zh-CN.yml @@ -90,3 +90,6 @@ zh-CN: trades: 交易 no_investments: 暂无投资账户 add_investment: 添加投资账户以跟踪您的投资组合 + release_notes_unavailable: + name: 发布说明暂不可用 + body_html: "

暂时无法获取最新发布说明。请稍后再试,或直接访问 GitHub releases 页面

" diff --git a/config/locales/views/password_resets/zh-CN.yml b/config/locales/views/password_resets/zh-CN.yml index 5bbf19447..ce5efcf3c 100644 --- a/config/locales/views/password_resets/zh-CN.yml +++ b/config/locales/views/password_resets/zh-CN.yml @@ -11,3 +11,5 @@ zh-CN: update: invalid_token: 验证令牌无效。 success: 密码已重置成功。 + disabled: Sure 密码重置已禁用。请通过身份提供商重置密码。 + sso_only_user: 你的账户使用 SSO 登录。请联系管理员管理凭据。 diff --git a/config/locales/views/plaid_items/zh-CN.yml b/config/locales/views/plaid_items/zh-CN.yml index bacb08f13..633c13d3a 100644 --- a/config/locales/views/plaid_items/zh-CN.yml +++ b/config/locales/views/plaid_items/zh-CN.yml @@ -21,8 +21,17 @@ zh-CN: status_never: 需要数据同步 syncing: 同步中... update: 更新 + deletion_in_progress: "(正在删除...)" select_existing_account: cancel: 取消 description: 选择一个 Plaid 账户与您的现有账户关联 link_account: 关联账户 title: 将 %{account_name} 关联到 Plaid + no_available_accounts: 没有可关联的 Plaid 账户。请先连接新的 Plaid 账户。 + errors: + link_token_generic: 暂时无法打开 Plaid。请重试;如果问题仍然存在,请查看服务器日志。 + link_token_with_message: Plaid 无法打开连接:%{message} + link_existing_account: + invalid_account: 选择的 Plaid 账户无效。 + already_linked: 此 Plaid 账户已关联。 + success: 账户已成功关联到 Plaid。 diff --git a/config/locales/views/properties/zh-CN.yml b/config/locales/views/properties/zh-CN.yml index cddfe072f..952c8b7c7 100644 --- a/config/locales/views/properties/zh-CN.yml +++ b/config/locales/views/properties/zh-CN.yml @@ -20,6 +20,7 @@ zh-CN: region_placeholder: 加利福尼亚州 year_built: 建造年份 year_built_placeholder: '2000' + subtype_prompt: 选择房产类型 new: title: 手动输入房产 next: 下一步 @@ -35,6 +36,7 @@ zh-CN: country_label: 国家 country_placeholder: 美国 save: 保存 + postal_code_label: 邮政编码 balances: title: 手动输入房产 market_value_label: 估算市值 diff --git a/config/locales/views/reports/zh-CN.yml b/config/locales/views/reports/zh-CN.yml index 8fe8cf3cb..fdf9cde4c 100644 --- a/config/locales/views/reports/zh-CN.yml +++ b/config/locales/views/reports/zh-CN.yml @@ -142,6 +142,7 @@ zh-CN: title: 投资表现 portfolio_value: 投资组合价值 total_return: 总回报 + period_return: 本期回报 contributions: 本期投入 withdrawals: 本期提取 top_holdings: 主要持仓 @@ -232,6 +233,7 @@ zh-CN: weight: 占比 value: 金额 return: 回报 + period_return: 本期回报 spending: title: 按分类支出 income: 收入 diff --git a/config/locales/views/settings/api_keys/zh-CN.yml b/config/locales/views/settings/api_keys/zh-CN.yml index 611099f18..2692b60fc 100644 --- a/config/locales/views/settings/api_keys/zh-CN.yml +++ b/config/locales/views/settings/api_keys/zh-CN.yml @@ -106,3 +106,10 @@ zh-CN: usage_instructions: 使用 API 密钥时,请将其包含在 X-Api-Key 请求头中: copy_key: 复制 API 密钥 continue: 继续前往 API 密钥设置 + create: + success: API 密钥已成功创建 + destroy: + not_found: 找不到 API 密钥 + cannot_revoke: 此 API 密钥不能撤销 + revoked_successfully: API 密钥已撤销 + revoke_failed: 撤销 API 密钥失败 diff --git a/config/locales/views/settings/hostings/en.yml b/config/locales/views/settings/hostings/en.yml index 557b8317b..55c0d1256 100644 --- a/config/locales/views/settings/hostings/en.yml +++ b/config/locales/views/settings/hostings/en.yml @@ -205,6 +205,8 @@ en: clear_cache: cache_cleared: Data cache has been cleared. This may take a few moments to complete. not_authorized: You are not authorized to perform this action + ensure_admin: + not_authorized: You are not authorized to perform this action ensure_super_admin_for_onboarding: not_authorized: You are not authorized to perform this action sync_auto_sync_scheduler!: diff --git a/config/locales/views/settings/hostings/zh-CN.yml b/config/locales/views/settings/hostings/zh-CN.yml index 176679e10..d6a6e5c7e 100644 --- a/config/locales/views/settings/hostings/zh-CN.yml +++ b/config/locales/views/settings/hostings/zh-CN.yml @@ -7,6 +7,13 @@ zh-CN: label: Client ID placeholder: 在此输入您的 Client ID title: Brand Fetch 设置 + env_configured_message: 已通过环境变量配置 Brandfetch。 + show_details: "(显示详情)" + setup_step_1_html: 前往 Brandfetch 并创建 API Key。 + setup_step_2_html: 复制 API Key。 + setup_step_3: 将 API Key 粘贴到下方。 + high_res_label: 高清 Logo + high_res_description: 启用后优先获取更高分辨率的品牌资源。 clear_cache: cache_cleared: 数据缓存已清除,此操作可能需要一些时间完成。 invite_code_settings: @@ -20,6 +27,9 @@ zh-CN: invite_only: 仅限邀请注册 open: 开放注册 title: 新用户注册设置 + default_family_title: 新用户默认家庭 + default_family_description: 选择使用邀请码注册的新用户默认加入哪个家庭。 + default_family_none: 无(创建新家庭) not_authorized: 您没有执行此操作的权限 openai_settings: access_token_label: 访问令牌 @@ -37,6 +47,14 @@ zh-CN: title: OpenAI 设置 uri_base_label: API 基础 URL(可选) uri_base_placeholder: https://api.openai.com/v1(默认) + budget_heading: Token 预算 + budget_description: 控制每次 AI 请求可使用的 Token 上限。 + context_window_label: 上下文窗口(可选) + context_window_help: 留空时使用模型默认上下文窗口。 + max_response_tokens_label: 最大响应 Token(可选) + max_response_tokens_help: 限制模型单次回答可生成的 Token 数。 + max_items_per_call_label: 每次调用最大项目数 + max_items_per_call_help: 限制单次请求传给模型的数据项数量。 provider_selection: description: 选择用于获取汇率和证券价格的服务。Yahoo Finance 免费且无需 API 密钥。Twelve Data 需要免费的 API 密钥,但可能提供更全面的数据覆盖。 @@ -45,8 +63,24 @@ zh-CN: providers: twelve_data: Twelve Data yahoo_finance: Yahoo Finance + tiingo: Tiingo + eodhd: EODHD + alpha_vantage: Alpha Vantage + mfapi: MFAPI.in + binance_public: Binance securities_provider_label: 证券(股票价格)数据提供商 title: 数据提供商选择 + exchange_rate_title: 汇率提供商 + exchange_rate_description: 选择用于同步汇率数据的提供商。 + securities_title: 证券价格提供商 + securities_description: 选择用于同步证券和投资价格的提供商。 + twelve_data_hint: Twelve Data 支持股票、ETF 和部分基金价格。 + yahoo_finance_hint: Yahoo Finance 可作为免密钥价格来源。 + requires_api_key: 需要 API Key + requires_api_key_eodhd: EODHD 需要 API Token。 + requires_api_key_alpha_vantage: Alpha Vantage 需要 API Key。 + mfapi_hint: MFAPI.in 可用于部分印度基金数据。 + binance_public_hint: Binance 公共接口可用于加密资产行情。 show: clear_cache: 清除数据缓存 clear_cache_warning: 清除数据缓存将移除所有汇率、证券价格、账户余额及其他数据。这不会删除账户、交易记录、分类或其他用户自有数据。 @@ -58,6 +92,8 @@ zh-CN: general: 通用设置 invites: 邀请码设置 title: 自托管配置 + ai_assistant: AI 助手 + sync_settings: 同步设置 twelve_data_settings: api_calls_used: 已使用 %{used} / %{limit} 次每日 API 调用(%{percentage}) description: 请输入 Twelve Data 提供的 API 密钥 @@ -66,10 +102,23 @@ zh-CN: placeholder: 在此输入您的 API 密钥 plan: "%{plan} 套餐" title: Twelve Data 设置 + show_details: "(显示详情)" + step_1_html: 注册或登录 Twelve Data。 + step_2_html: 前往 API Keys 页面。 + step_3: 显示 API 密钥并粘贴到下方。 + plan_upgrade_warning_title: 部分代码需要付费计划 + plan_upgrade_warning_description: 当前 Twelve Data 计划无法同步下列持仓价格。 + requires_plan: 需要 %{plan} 计划 + view_pricing: 查看 Twelve Data 价格 update: failure: 设置值无效 invalid_onboarding_state: 无效的新用户注册状态 success: 设置已更新 + invalid_sync_time: 同步时间无效。 + invalid_llm_budget: "%{field} 必须是大于等于 %{minimum} 的整数。" + invalid_anthropic_base_url: Anthropic Base URL 无效。 + anthropic_model_required_for_base_url: 设置 Anthropic Base URL 时必须填写模型。 + scheduler_sync_failed: 设置已保存,但同步计划更新失败。请重试或检查服务器日志。 yahoo_finance_settings: connection_failed: 无法连接到 Yahoo Finance description: Yahoo Finance 提供免费的股票价格、汇率和金融数据访问,无需 API 密钥。 @@ -77,3 +126,99 @@ zh-CN: status_inactive: Yahoo Finance 连接失败 title: Yahoo Finance 设置 troubleshooting: 请检查您的互联网连接和防火墙设置。Yahoo Finance 可能暂时不可用。 + assistant_settings: + title: AI 助手 + description: 配置 Sure 使用的 AI 助手模式。 + type_label: 助手类型 + type_builtin: 内置(直接调用 LLM) + type_external: 外部(远程代理) + external_status: 外部助手端点 + external_configured: 已配置 + external_not_configured: 外部助手尚未配置。 + env_notice: 已通过环境变量配置 %{type}。 + env_configured_external: 外部助手已通过环境变量配置。 + url_label: 端点 URL + url_placeholder: 输入外部助手端点 URL + url_help: 外部助手服务的完整访问地址。 + token_label: API 令牌 + token_placeholder: 输入 API 令牌 + token_help: 用于验证外部助手请求的令牌。 + agent_id_label: Agent ID(可选) + agent_id_placeholder: main(默认) + agent_id_help: 留空时使用默认代理。 + disconnect_title: 外部连接 + disconnect_description: 断开当前外部助手连接。 + disconnect_button: 断开连接 + confirm_disconnect: + title: 断开外部助手? + body: 断开后将无法继续使用此远程助手。 + llm_provider_selector: + title: AI 提供商 + description: 选择 Sure 调用的 LLM 提供商。 + env_configured_message: 已通过环境变量配置 LLM 提供商。 + provider_label: 当前 LLM 提供商 + provider_openai: OpenAI + provider_anthropic: Anthropic (Claude) + provider_help: 更改后会影响新的 AI 请求。 + not_configured_hint: "%{provider} 尚未配置。" + data_retention_heading: 数据处理 + data_retention: 发送给 AI 提供商的数据可能包含财务上下文,请按你的部署策略配置。 + anthropic_settings: + title: Anthropic (Claude) + description: 使用 Anthropic Claude 作为 AI 提供商。 + env_configured_message: 已通过环境变量配置 Anthropic。 + access_token_label: API 密钥 + access_token_placeholder: 输入 Anthropic API Key + base_url_label: Base URL(可选) + base_url_placeholder: 自定义 Anthropic Base URL + model_label: 默认模型(可选) + model_placeholder: claude-sonnet-4-6(默认) + model_help: 留空时使用系统默认模型。 + tiingo_settings: + title: Tiingo + description: 使用 Tiingo 同步证券价格。 + env_configured_message: 已通过环境变量配置 Tiingo。 + label: API 令牌 + placeholder: 粘贴 Tiingo API Token + show_details: "(显示详情)" + step_1_html: 注册或登录 Tiingo。 + step_2_html: 前往 API Token 页面。 + step_3: 复制 API Token 并粘贴到下方。 + eodhd_settings: + title: EODHD + description: 使用 EODHD 同步证券价格。 + env_configured_message: 已通过环境变量配置 EODHD。 + label: API 令牌 + placeholder: 粘贴 EODHD API Token + show_details: "(显示详情)" + step_1_html: 注册或登录 EODHD。 + step_2_html: 打开 API Token 页面。 + step_3: 复制 API Token 并粘贴到下方。 + rate_limit_warning: 免费或低阶计划可能有调用频率限制。 + alpha_vantage_settings: + title: Alpha Vantage + description: 使用 Alpha Vantage 同步证券价格。 + env_configured_message: 已通过环境变量配置 Alpha Vantage。 + label: API 密钥 + placeholder: 粘贴 Alpha Vantage API Key + show_details: "(显示详情)" + step_1_html: 注册或登录 Alpha Vantage。 + step_2: 前往 API Key 页面并复制密钥。 + rate_limit_warning: 免费计划可能有调用频率限制。 + no_health_check_note: 此提供商暂无健康检查。 + disconnect_external_assistant: + external_assistant_disconnected: 外部助手已断开连接。 + ensure_admin: + not_authorized: 您没有执行此操作的权限。 + ensure_super_admin_for_onboarding: + not_authorized: 只有超级管理员可以访问此设置。 + sync_auto_sync_scheduler!: + scheduler_sync_failed: 自动同步计划更新失败。 + sync_settings: + auto_sync_label: 启用自动同步 + auto_sync_description: 按计划自动同步已连接账户。 + auto_sync_time_label: 同步时间(HH:MM) + auto_sync_time_description: 每天触发自动同步的时间。 + include_pending_label: 包含待处理交易 + include_pending_description: 同步时同时拉取提供商返回的待处理交易。 + env_configured_message: 已通过环境变量配置同步设置。 diff --git a/config/locales/views/settings/securities/zh-CN.yml b/config/locales/views/settings/securities/zh-CN.yml index 1a3adb6cf..98d88ddab 100644 --- a/config/locales/views/settings/securities/zh-CN.yml +++ b/config/locales/views/settings/securities/zh-CN.yml @@ -8,3 +8,24 @@ zh-CN: enable_mfa: 启用双重认证 mfa_description: 在登录时要求输入身份验证器应用中的代码,为账户增加额外安全层 mfa_title: 双重认证 + mfa_enabled_status_html: 双重认证已启用 + mfa_enabled_description: 你的账户受到额外安全层保护。 + mfa_disabled_status_html: 双重认证已禁用 + mfa_disabled_description: 启用双重认证,为账户增加一层保护。 + webauthn_add: 添加通行密钥或安全密钥 + webauthn_added: 添加于 %{date} + webauthn_description: 使用通行密钥、Touch ID、Windows Hello 或硬件安全密钥作为登录第二因素。 + webauthn_empty: 暂未注册通行密钥或安全密钥。 + webauthn_last_used: 上次使用于 %{time_ago} 前 + webauthn_name_label: 密钥名称 + webauthn_name_placeholder: MacBook Touch ID、YubiKey 等 + webauthn_remove: 移除 + webauthn_remove_confirm: 确定要移除此通行密钥或安全密钥? + webauthn_remove_confirm_body: 移除后,你需要重新注册才能再次用于登录验证。 + webauthn_title: 通行密钥和安全密钥 + webauthn_unsupported: 此浏览器不支持通行密钥或安全密钥。 + webauthn_credentials: + default_name: 安全密钥 + failure: 无法保存该通行密钥或安全密钥。请重试。 + mfa_required: 添加通行密钥或安全密钥前,请先启用双重认证。 + success: 通行密钥或安全密钥已移除。 diff --git a/config/locales/views/settings/zh-CN.yml b/config/locales/views/settings/zh-CN.yml index 6f8716019..296573b34 100644 --- a/config/locales/views/settings/zh-CN.yml +++ b/config/locales/views/settings/zh-CN.yml @@ -163,6 +163,7 @@ zh-CN: member_removal_failed: 移除成员时出现问题。 member_removed: 成员已成功移除。 not_authorized: 您没有权限移除成员。 + member_owns_other_family_data: 此成员仍拥有其他家庭的数据,无法移除。请先转移或删除这些账户。 show: confirm_delete: body: 确定要永久删除您的账户吗?此操作不可逆。 @@ -327,6 +328,7 @@ zh-CN: sophtron: 连接美国和加拿大的银行及公用事业账户。 plaid: 通过 Plaid 连接数千家美国金融机构。 plaid_eu: 通过 Plaid 连接欧洲金融机构(PSD2 / Open Banking)。 + akahu: 通过 Akahu 同步新西兰金融机构。 search_filters: aria_label: 搜索提供商 placeholder: 搜索提供商 @@ -373,6 +375,9 @@ zh-CN: syncing: 正在同步... sync: 同步 disconnect_confirm: 确定要断开 Binance 吗? + historical_import: 历史导入设置 + sync_start_date_label: 导入起始日期 + sync_start_date_help: 选择要向前获取历史交易的时间范围。 kraken_panel: step1_html: '前往 Kraken API 设置' step2: 仅创建一个具备 Query Funds 和 Query Closed Orders & Trades 权限的 API key。 @@ -506,3 +511,7 @@ zh-CN: accounts_tab: 账户 status_configured_suffix: 标签页以管理已发现的账户。 not_configured: 未配置。 + akahu_panel: + step_1_html: 前往 %{link} 并创建个人应用。 + step_2: 复制 App Token 和 User Token。 + step_3: 粘贴 Token、保存,然后关联同步到账户。 diff --git a/config/locales/views/shared/zh-CN.yml b/config/locales/views/shared/zh-CN.yml index 2f5964b75..074022c1b 100644 --- a/config/locales/views/shared/zh-CN.yml +++ b/config/locales/views/shared/zh-CN.yml @@ -36,3 +36,7 @@ zh-CN: expense: 支出 income: 收入 transfer: 转账 + preview: 预览 + sync_toast: + message: 有新数据可用 + refresh: 刷新 diff --git a/config/locales/views/simplefin_items/zh-CN.yml b/config/locales/views/simplefin_items/zh-CN.yml index 65ca9fe27..66595ca9d 100644 --- a/config/locales/views/simplefin_items/zh-CN.yml +++ b/config/locales/views/simplefin_items/zh-CN.yml @@ -155,5 +155,5 @@ zh-CN: other: 已勾稽 %{count} 笔重复待处理交易 stale_pending_status: message: - one: 1 笔待处理交易早于 %{days} 天 + one: "%{count} 笔待处理交易早于 %{days} 天" other: "%{count} 笔待处理交易早于 %{days} 天" diff --git a/config/locales/views/subscriptions/zh-CN.yml b/config/locales/views/subscriptions/zh-CN.yml index 41da3c50a..eab05cd1d 100644 --- a/config/locales/views/subscriptions/zh-CN.yml +++ b/config/locales/views/subscriptions/zh-CN.yml @@ -11,4 +11,14 @@ zh-CN: today: "今天" redirect_to_stripe: "在下一步中,您将被重定向到 Stripe,它为我们处理信用卡。" trialing: "您的数据将在 %{days} 天后删除" - trial_over: "您的试用期已结束" \ No newline at end of file + trial_over: "您的试用期已结束" + already_contributing: 你已经在贡献。谢谢! + page_title: 升级 + account_settings: 账户设置 + sign_out: 退出登录 + create: + welcome: 欢迎使用 Sure! + trial_already_used: 你已经开始或完成过试用。请升级以继续使用。 + success: + welcome_with_contribution: 欢迎使用 Sure!感谢你的贡献。 + contribution_failed: 处理贡献时出现问题。请重试。 diff --git a/config/locales/views/tag/deletions/zh-CN.yml b/config/locales/views/tag/deletions/zh-CN.yml index 816eecfd2..7fd19adc5 100644 --- a/config/locales/views/tag/deletions/zh-CN.yml +++ b/config/locales/views/tag/deletions/zh-CN.yml @@ -11,3 +11,4 @@ zh-CN: explanation: "“%{tag_name}”将从交易及其他可标记实体中移除。您也可以在下述选项中选择一个新标签进行重新分配,而不是保留为未标记状态。" replacement_tag_prompt: 选择新标签 tag: 标签 + delete_and_reassign: 删除并重新分配 diff --git a/config/locales/views/tags/zh-CN.yml b/config/locales/views/tags/zh-CN.yml index c133ffe3f..43abe87a5 100644 --- a/config/locales/views/tags/zh-CN.yml +++ b/config/locales/views/tags/zh-CN.yml @@ -14,6 +14,7 @@ zh-CN: empty: 暂无标签 new: 新建标签 tags: 标签管理 + delete_all: 删除全部 new: new: 新建标签 tag: @@ -21,3 +22,5 @@ zh-CN: edit: 编辑 update: updated: 标签已更新 + destroy_all: + all_deleted: 所有标签已删除 diff --git a/config/locales/views/transactions/zh-CN.yml b/config/locales/views/transactions/zh-CN.yml index ebf13802a..3f14fc218 100644 --- a/config/locales/views/transactions/zh-CN.yml +++ b/config/locales/views/transactions/zh-CN.yml @@ -288,6 +288,14 @@ zh-CN: status_filter: confirmed: 已确认 pending: 待处理 + account_filter: + filter_accounts: 筛选账户 + category_filter: + filter_category: 筛选分类 + merchant_filter: + filter_merchants: 筛选商户 + tag_filter: + filter_tags: 筛选标签 menu: account_filter: 账户 amount_filter: 金额 @@ -326,3 +334,16 @@ zh-CN: other: 文件(%{count}) browse_to_add: 浏览并添加文件 max_reached: 已达到最大文件数(%{count}/%{max})。删除现有文件后才能上传另一个。 + potential_duplicate_description: 这笔待处理交易可能与下方已入账交易相同。如是,请合并以避免重复计算。 + transaction: + pending: 待处理 + pending_tooltip: 待处理交易,入账后可能变化 + linked_with_provider: 已关联 %{provider} + activity_type_tooltip: 投资活动类型 + possible_duplicate: 重复? + potential_duplicate_tooltip: 这可能是另一笔交易的重复项 + review_recommended: 查看 + review_recommended_tooltip: 金额差异较大,建议检查是否重复 + split: 拆分 + split_tooltip: 此交易已拆分为多条记录 + split_child_tooltip: 拆分交易的一部分 diff --git a/config/locales/views/transfers/zh-CN.yml b/config/locales/views/transfers/zh-CN.yml index ec1f1d714..25d7070c5 100644 --- a/config/locales/views/transfers/zh-CN.yml +++ b/config/locales/views/transfers/zh-CN.yml @@ -15,6 +15,14 @@ zh-CN: submit: 创建转账 to: 转入账户 transfer: 转账 + calculate_rate_tab: 计算外汇汇率 + convert_tab: 按外汇汇率换算 + destination_amount: 目标金额 + destination_amount_display: 目标金额:%{amount} + exchange_rate: 汇率 + exchange_rate_display: 汇率:%{rate} + exchange_rate_help: 选择转账金额的输入方式。 + source_amount: 来源金额 new: title: 新建转账 show: @@ -26,5 +34,14 @@ zh-CN: note_placeholder: 为此转账添加备注 overview: 概览 settings: 设置 + mark_recurring: 标记为循环 + mark_recurring_subtitle: 在后续流水和循环页面中按循环模式跟踪此转账。 + mark_recurring_title: 标记为循环转账 + from: 从 + to: 到 + date: 日期 + amount: 金额 + category: 分类 + uncategorized: 未分类 update: success: 转账已更新 diff --git a/config/locales/views/users/zh-CN.yml b/config/locales/views/users/zh-CN.yml index 5c9d5890f..3b4da5f8a 100644 --- a/config/locales/views/users/zh-CN.yml +++ b/config/locales/views/users/zh-CN.yml @@ -16,3 +16,15 @@ zh-CN: email_change_failed: 邮箱地址更改失败。 email_change_initiated: 请查收新邮箱地址的确认邮件以完成更改。 success: 个人资料已更新成功。 + user_menu: + aria_label: 打开账户菜单 + version: 版本 + settings: 设置 + changelog: 更新日志 + feedback: 反馈 + contact: 联系我们 + log_out: 退出登录 + roles: + admin: 管理员 + member: 成员 + super_admin: 超级管理员 diff --git a/config/locales/views/valuations/zh-CN.yml b/config/locales/views/valuations/zh-CN.yml index d0b1efc7a..773e15ebf 100644 --- a/config/locales/views/valuations/zh-CN.yml +++ b/config/locales/views/valuations/zh-CN.yml @@ -2,7 +2,7 @@ zh-CN: valuations: confirmation_contents: - this_will: 这将把账户价值在 + this_will: 这将%{action_verb}账户在 to_colon: 改为: total_account_value: 账户总价值 holdings_value: 持仓价值 @@ -16,7 +16,7 @@ zh-CN: asset_value: 资产价值 liability_balance: 负债余额 balance: 余额 - on: 在 + "on": 在 to: 到 recalculate_notice: 所有未来的交易和余额都将根据此%{change_or_update}重新计算。 change: 变更 @@ -42,6 +42,8 @@ zh-CN: value: 价值 new: title: 新余额 + amount: 金额 + submit: 保存估值 show: amount: 金额 amount_label: 此日期的账户价值 diff --git a/config/locales/views/vehicles/zh-CN.yml b/config/locales/views/vehicles/zh-CN.yml index 01c1f3f0c..c64c92a97 100644 --- a/config/locales/views/vehicles/zh-CN.yml +++ b/config/locales/views/vehicles/zh-CN.yml @@ -32,3 +32,4 @@ zh-CN: trend: 走势 unknown: 未知 year: 年份 + edit_account_details: 编辑账户详情 From 08bfd4377589dff86fbd9f4c4c4515f8673f0ad9 Mon Sep 17 00:00:00 2001 From: "Sure Admin (bot)" Date: Mon, 15 Jun 2026 20:32:59 +0200 Subject: [PATCH 05/11] fix: preserve CoinStats balances when wallet sync data is missing (#2132) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: cover CoinStats missing-wallet balance preservation * fix: preserve CoinStats balances when wallet data is missing * chore: route CoinStats sync warnings to debug log * docs: make debug log guidance timeless --------- Signed-off-by: Juan José Mata Co-authored-by: Juan José Mata --- AGENTS.md | 9 + CLAUDE.md | 6 + app/models/coinstats_item/importer.rb | 74 +++++++- test/models/coinstats_item/importer_test.rb | 192 +++++++++++++++++++- 4 files changed, 268 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c5359f025..399375924 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,6 +63,15 @@ Reviewers escalate violations of (2)–(3) to close/rewrite; (1) and (4) are req If you need to add a new securities price provider (Tiingo, EODHD, Binance-style crypto, etc.), see [adding-a-securities-provider.md](./docs/llm-guides/adding-a-securities-provider.md) for the full walkthrough — provider class, registry wiring, MIC handling, settings UI, locales, and tests. +## Debug Logging for Provider Syncs + +When a provider sync/import path hits a recoverable error or suspicious partial response that support may need to inspect later, prefer `DebugLogEntry.capture(...)` over `Rails.logger.*`. + +- Record support-relevant diagnostics in the debug log so they surface in the super-admin-friendly `/settings/debug` UI. +- Include `category`, `level`, `message`, `source`, `provider_key`, and useful structured `metadata`. +- Attach `family` and `account_provider` when available so support can filter and trace the affected connection. +- Reserve raw Rails logging for low-value local noise; anything operators may need should go to the debug log. + ## Providers: Pending Transactions and FX Metadata (SimpleFIN/Plaid/Lunchflow) - Pending detection diff --git a/CLAUDE.md b/CLAUDE.md index 4fc9fcf10..5464699de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,6 +132,12 @@ Sidekiq handles asynchronous tasks: - AI chat responses (`AssistantResponseJob`) - Scheduled maintenance via sidekiq-cron +### Debug Logging for Provider Syncs +- Prefer `DebugLogEntry.capture(...)` over `Rails.logger.*` for provider sync/import failures, partial responses, and other support-relevant diagnostics. +- Record support-relevant incidents in the super-admin `/settings/debug` UI rather than leaving them only in raw application logs. +- Include `category`, `level`, `message`, `source`, `provider_key`, and structured `metadata`. +- Attach `family` and `account_provider` whenever possible so support can filter to the affected provider connection. + ### Frontend Architecture - **Hotwire Stack**: Turbo + Stimulus for reactive UI without heavy JavaScript - **ViewComponents**: Reusable UI components in `app/components/` diff --git a/app/models/coinstats_item/importer.rb b/app/models/coinstats_item/importer.rb index 8c2075daf..2a5f77b32 100644 --- a/app/models/coinstats_item/importer.rb +++ b/app/models/coinstats_item/importer.rb @@ -149,8 +149,17 @@ class CoinstatsItem::Importer response = coinstats_provider.get_wallet_balances(wallets_param) response.success? ? response.data : FETCH_FAILED rescue => e - Rails.logger.warn "CoinstatsItem::Importer - Bulk balance fetch failed: #{e.message}" - FETCH_FAILED + log_debug_event( + level: "warn", + message: "CoinStats bulk balance fetch failed", + metadata: { + coinstats_item_id: coinstats_item.id, + wallets: wallets.map { |wallet| "#{wallet[:blockchain]}:#{wallet[:address]}" }, + error_class: e.class.name, + error_message: e.message + } + ) + nil end def fetch_portfolio_coins_for_exchange(linked_accounts) @@ -244,15 +253,38 @@ class CoinstatsItem::Importer return { success: false, error: "Missing address or blockchain" } end - # Extract balance data for this specific wallet from the bulk response - balance_data = if bulk_balance_data.present? - coinstats_provider.extract_wallet_balance(bulk_balance_data, address, blockchain) + if bulk_balance_data.nil? + log_debug_event( + level: "warn", + message: "CoinStats wallet balance sync preserved existing snapshot after bulk fetch failure", + account_provider: coinstats_account.account_provider, + metadata: { + coinstats_item_id: coinstats_item.id, + coinstats_account_id: coinstats_account.id, + wallet_address: address, + blockchain: blockchain, + reason: "bulk_balance_data_missing" + } + ) + elsif wallet_missing_from_bulk_balance_data?(bulk_balance_data, address, blockchain) + log_debug_event( + level: "warn", + message: "CoinStats wallet balance sync preserved existing snapshot because wallet was missing from bulk response", + account_provider: coinstats_account.account_provider, + metadata: { + coinstats_item_id: coinstats_item.id, + coinstats_account_id: coinstats_account.id, + wallet_address: address, + blockchain: blockchain, + reason: "wallet_missing_from_bulk_response" + } + ) else - [] - end + balance_data = coinstats_provider.extract_wallet_balance(bulk_balance_data, address, blockchain) - # Update the coinstats account with new balance data - coinstats_account.upsert_coinstats_snapshot!(normalize_balance_data(balance_data, coinstats_account)) + # Update the coinstats account with new balance data + coinstats_account.upsert_coinstats_snapshot!(normalize_balance_data(balance_data, coinstats_account)) + end # Extract and merge transactions from bulk response transactions_count = fetch_and_merge_transactions(coinstats_account, address, blockchain, bulk_transactions_data) @@ -260,6 +292,30 @@ class CoinstatsItem::Importer { success: true, transactions_count: transactions_count } end + def wallet_missing_from_bulk_balance_data?(bulk_balance_data, address, blockchain) + return true unless bulk_balance_data.is_a?(Array) + + bulk_balance_data.none? do |entry| + entry = entry.with_indifferent_access + entry[:address]&.downcase == address&.downcase && + (entry[:connectionId]&.downcase == blockchain&.downcase || + entry[:blockchain]&.downcase == blockchain&.downcase) + end + end + + def log_debug_event(level:, message:, metadata:, account_provider: nil) + DebugLogEntry.capture( + category: "provider_sync_error", + level: level, + message: message, + source: self.class.name, + provider_key: "coinstats", + family: coinstats_item.family, + account_provider: account_provider, + metadata: metadata + ) + end + def update_exchange_account(coinstats_account, portfolio_coins_data:, portfolio_transactions_data:) portfolio_id = exchange_portfolio_id_for(coinstats_account) balance_data = portfolio_coins_data[portfolio_id] diff --git a/test/models/coinstats_item/importer_test.rb b/test/models/coinstats_item/importer_test.rb index 823a84945..a07a7526e 100644 --- a/test/models/coinstats_item/importer_test.rb +++ b/test/models/coinstats_item/importer_test.rb @@ -465,10 +465,6 @@ class CoinstatsItem::ImporterTest < ActiveSupport::TestCase .with(bulk_response, "0xworking", "ethereum") .returns([ { coinId: "ethereum", name: "Ethereum", amount: 1.0, price: 2000 } ]) - @mock_provider.expects(:extract_wallet_balance) - .with(bulk_response, "0xfailing", "ethereum") - .returns([]) # Empty array for missing wallet - bulk_transactions_response = [ { blockchain: "ethereum", @@ -498,6 +494,194 @@ class CoinstatsItem::ImporterTest < ActiveSupport::TestCase assert_equal 0, result[:accounts_failed] end + test "multi-wallet import preserves an existing wallet when bulk response omits it" do + crypto1 = Crypto.create! + account1 = @family.accounts.create!( + accountable: crypto1, + name: "Working Wallet", + balance: 2000, + currency: "USD" + ) + coinstats_account1 = @coinstats_item.coinstats_accounts.create!( + name: "Working Wallet", + currency: "USD", + current_balance: 2000, + account_id: "ethereum", + raw_payload: { + address: "0xworking", + blockchain: "ethereum", + coinId: "ethereum", + amount: 1.0, + price: 2000, + balance: 2000 + } + ) + AccountProvider.create!(account: account1, provider: coinstats_account1) + + crypto2 = Crypto.create! + account2 = @family.accounts.create!( + accountable: crypto2, + name: "DOGE Wallet", + balance: 100, + currency: "USD" + ) + coinstats_account2 = @coinstats_item.coinstats_accounts.create!( + name: "DOGE Wallet", + currency: "USD", + current_balance: 100, + account_id: "dogecoin", + raw_payload: { + address: "Ddoge123", + blockchain: "dogecoin", + coinId: "dogecoin", + amount: 1000, + price: 0.1, + balance: 100 + } + ) + AccountProvider.create!(account: account2, provider: coinstats_account2) + + bulk_response = [ + { + blockchain: "dogecoin", + address: "Ddoge123", + connectionId: "dogecoin", + balances: [ { coinId: "dogecoin", name: "Dogecoin", amount: 1000, price: 0.1 } ] + } + ] + + @mock_provider.expects(:get_wallet_balances) + .with("ethereum:0xworking,dogecoin:Ddoge123") + .returns(success_response(bulk_response)) + + @mock_provider.expects(:extract_wallet_balance) + .with(bulk_response, "Ddoge123", "dogecoin") + .returns([ { coinId: "dogecoin", name: "Dogecoin", amount: 1000, price: 0.1 } ]) + + bulk_transactions_response = [ + { + blockchain: "dogecoin", + address: "Ddoge123", + connectionId: "dogecoin", + transactions: [] + } + ] + + @mock_provider.expects(:get_wallet_transactions) + .with("ethereum:0xworking,dogecoin:Ddoge123") + .returns(success_response(bulk_transactions_response)) + + @mock_provider.expects(:extract_wallet_transactions) + .with(bulk_transactions_response, "0xworking", "ethereum") + .returns([]) + + @mock_provider.expects(:extract_wallet_transactions) + .with(bulk_transactions_response, "Ddoge123", "dogecoin") + .returns([]) + + assert_difference "DebugLogEntry.count", 1 do + result = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider).import + + assert result[:success] + end + + coinstats_account1.reload + coinstats_account2.reload + + assert_equal 2000.0, coinstats_account1.current_balance.to_f, "missing wallet data should not zero an existing wallet" + assert_equal 100.0, coinstats_account2.current_balance.to_f + + entry = DebugLogEntry.order(:created_at).last + assert_equal "provider_sync_error", entry.category + assert_equal "warn", entry.level + assert_equal "CoinStats wallet balance sync preserved existing snapshot because wallet was missing from bulk response", entry.message + assert_equal "coinstats", entry.provider_key + assert_equal @family.id, entry.family_id + assert_equal coinstats_account1.account_provider.id, entry.account_provider_id + assert_equal "wallet_missing_from_bulk_response", entry.metadata["reason"] + assert_equal "0xworking", entry.metadata["wallet_address"] + end + + test "bulk balance fetch failure preserves all existing wallet balances during import" do + crypto1 = Crypto.create! + account1 = @family.accounts.create!( + accountable: crypto1, + name: "Ethereum Wallet", + balance: 2000, + currency: "USD" + ) + coinstats_account1 = @coinstats_item.coinstats_accounts.create!( + name: "Ethereum Wallet", + currency: "USD", + current_balance: 2000, + account_id: "ethereum", + raw_payload: { + address: "0xeth123", + blockchain: "ethereum", + coinId: "ethereum", + amount: 1.0, + price: 2000, + balance: 2000 + } + ) + AccountProvider.create!(account: account1, provider: coinstats_account1) + + crypto2 = Crypto.create! + account2 = @family.accounts.create!( + accountable: crypto2, + name: "DOGE Wallet", + balance: 100, + currency: "USD" + ) + coinstats_account2 = @coinstats_item.coinstats_accounts.create!( + name: "DOGE Wallet", + currency: "USD", + current_balance: 100, + account_id: "dogecoin", + raw_payload: { + address: "Ddoge456", + blockchain: "dogecoin", + coinId: "dogecoin", + amount: 1000, + price: 0.1, + balance: 100 + } + ) + AccountProvider.create!(account: account2, provider: coinstats_account2) + + @mock_provider.expects(:get_wallet_balances) + .with("ethereum:0xeth123,dogecoin:Ddoge456") + .raises(Provider::Coinstats::Error.new("CoinStats timeout")) + + bulk_transactions_response = [] + @mock_provider.expects(:get_wallet_transactions) + .with("ethereum:0xeth123,dogecoin:Ddoge456") + .returns(success_response(bulk_transactions_response)) + + assert_difference "DebugLogEntry.count", 3 do + result = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider).import + + assert result[:success] + end + + coinstats_account1.reload + coinstats_account2.reload + + assert_equal 2000.0, coinstats_account1.current_balance.to_f + assert_equal 100.0, coinstats_account2.current_balance.to_f + + entries = DebugLogEntry.order(:created_at).last(3) + fetch_failure_entry = entries.find { |entry| entry.message == "CoinStats bulk balance fetch failed" } + assert_not_nil fetch_failure_entry + assert_equal "provider_sync_error", fetch_failure_entry.category + assert_equal "coinstats", fetch_failure_entry.provider_key + assert_equal "CoinStats timeout", fetch_failure_entry.metadata["error_message"] + + preserved_entries = entries.select { |entry| entry.message == "CoinStats wallet balance sync preserved existing snapshot after bulk fetch failure" } + assert_equal 2, preserved_entries.size + assert_equal [ "0xeth123", "Ddoge456" ], preserved_entries.map { |entry| entry.metadata["wallet_address"] }.sort + end + test "uses bulk endpoint for multiple unique wallets and falls back on error" do # Create accounts with two different wallet addresses crypto1 = Crypto.create! From c29380ce577cd0b91fa1a58c440b85cc1012fe7f Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Mon, 15 Jun 2026 20:34:34 +0200 Subject: [PATCH 06/11] feat(dashboard): masonry packing + per-widget size controls (#2328) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(dashboard): masonry packing + per-widget size controls In two-column mode the dashboard used a row-based CSS grid, so cards stretched to equal row height and left dead space (e.g. the Net Worth chart padded out to match the tall Balance Sheet table). Replace the row-based layout with masonry packing and add per-widget size guardrails. - Masonry: CSS grid with computed row-spans + grid-auto-flow: dense, driven by a dashboard-masonry Stimulus controller (ResizeObserver + turbo:frame-load). The DOM stays a single flat list, so drag/keyboard reorder is unaffected. Active only in multi-column mode; single column falls back to normal flow. - Internal sizing: the net worth chart height is now driven by a --dash-widget-h CSS var (fixes an inert flex-1) so the card no longer pads out below the chart. - Guardrails: per-widget layout metadata (col_span, grow, min_height, width_toggle) in PagesController, with per-user overrides persisted under preferences["dashboard_section_layout"], deep-merged so width and height coexist. - Size menu: a hover control on size-capable cards — Width (Half/Full) for the cashflow sankey and net worth chart, Height (Compact/Auto/Tall) for grow widgets. The sankey defaults to full width. Adds model + controller tests for preference persistence and i18n keys. * refactor(dashboard): redesign size menu with segmented controls The size menu used a plain radio list and a diagonal maximize-2 trigger that collided with the cashflow sankey's modal-expand button. Replace it with a layout-config popover: a sliders-horizontal trigger plus two labeled axis groups (Width, Height), each rendered as a DS::SegmentedControl with the active option filled. Clearer, more compact, and it reads as a card-layout control. The widget-size controller now mirrors the segmented control's active-class + aria-pressed contract. * feat(dashboard): expose width toggle on balance sheet and investments Tables benefit from horizontal room, so give Balance Sheet and Investments the same Width (Half/Full) control as the sankey and net worth chart. Height presets stay chart-only — a table sized to a fixed height would just add whitespace or force scrolling. The Outflows donut is intentionally left out (full width is mostly whitespace for a donut). * fix(dashboard): address review feedback on size controls - Only apply the full-width col-span and show the Width control when the two-column layout is enabled. A full widget previously leaked 2xl:col-span-2 into the single-column grid, creating an implicit second column at 2xl widths and breaking the single-column preference. This also keeps the Width control coherent with the Appearance two-column setting (it now appears only where it does something). [Codex] - Stop size-menu keydowns from bubbling to the section reorder handler, so keyboard users can open the menu and pick options without entering grab/reorder mode. [Codex] - Harden preferences params: ignore a malformed (non-hash) dashboard_section_layout / collapsed_sections instead of raising a 500, and require section_order to be an array. [CodeRabbit] - Localize the dashboard sections aria-label. [CodeRabbit] * chore(settings): mention per-widget size controls in two-column copy Surface the new per-widget width/height controls in the Appearance "Two-column layout" description so the capability is discoverable. * test(dashboard): assert non-mutation for malformed layout input Addresses review feedback: asserting assert_nil made the test depend on the fixture happening to have no dashboard height for net_worth_chart. Capture the pre-PATCH value and assert it is unchanged, so the test stays valid (malformed input ignored) even if the fixture later gets a default height. --------- Signed-off-by: Juan José Mata Co-authored-by: Guillem Arias Co-authored-by: Juan José Mata --- app/controllers/pages_controller.rb | 44 ++++++- .../dashboard_masonry_controller.js | 82 +++++++++++++ .../dashboard_widget_size_controller.js | 110 ++++++++++++++++++ app/models/user.rb | 14 ++- app/views/pages/dashboard.html.erb | 57 ++++++++- .../pages/dashboard/_net_worth_chart.html.erb | 3 +- config/locales/views/pages/en.yml | 10 ++ config/locales/views/settings/en.yml | 2 +- test/controllers/pages_controller_test.rb | 29 +++++ test/models/user_test.rb | 39 +++++++ 10 files changed, 383 insertions(+), 7 deletions(-) create mode 100644 app/javascript/controllers/dashboard_masonry_controller.js create mode 100644 app/javascript/controllers/dashboard_widget_size_controller.js diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 90a1f1054..844b5e066 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -1,6 +1,24 @@ class PagesController < ApplicationController include Periodable + # Per-widget dashboard layout guardrails. Deterministic defaults the masonry + # packer reads; users may override a grow widget's height via presets. + # col_span: "single" | "full" (full spans both columns in 2-col mode) + # grow: true for charts that should fill an allotted height, + # false for content-sized widgets (tables, stat grids) + # min_height: floor in px + DASHBOARD_SECTION_LAYOUTS = { + "cashflow_sankey" => { col_span: "full", grow: false, min_height: 384, width_toggle: true }, + "outflows_donut" => { col_span: "single", grow: false, min_height: 0 }, + "investment_summary" => { col_span: "single", grow: false, min_height: 0, width_toggle: true }, + "net_worth_chart" => { col_span: "single", grow: true, min_height: 208, width_toggle: true }, + "balance_sheet" => { col_span: "single", grow: false, min_height: 0, width_toggle: true } + }.freeze + + # Selectable height presets (px) for grow widgets. + DASHBOARD_HEIGHT_PRESETS = { "compact" => 208, "auto" => 288, "tall" => 416 }.freeze + DEFAULT_HEIGHT_PRESET = "auto" + skip_authentication only: %i[redis_configuration_error privacy terms] before_action :ensure_intro_guest!, only: :intro @@ -78,8 +96,9 @@ class PagesController < ApplicationController def preferences_params prefs = params.require(:preferences) {}.tap do |permitted| - permitted["collapsed_sections"] = prefs[:collapsed_sections].to_unsafe_h if prefs[:collapsed_sections] - permitted["section_order"] = prefs[:section_order] if prefs[:section_order] + permitted["collapsed_sections"] = prefs[:collapsed_sections].to_unsafe_h if prefs[:collapsed_sections].respond_to?(:to_unsafe_h) + permitted["section_order"] = prefs[:section_order] if prefs[:section_order].is_a?(Array) + permitted["dashboard_section_layout"] = prefs[:dashboard_section_layout].to_unsafe_h if prefs[:dashboard_section_layout].respond_to?(:to_unsafe_h) end end @@ -89,6 +108,7 @@ class PagesController < ApplicationController key: "cashflow_sankey", title: "pages.dashboard.cashflow_sankey.title", partial: "pages/dashboard/cashflow_sankey", + layout: section_layout("cashflow_sankey"), locals: { sankey_data: @cashflow_sankey_data, period: @period }, visible: @accounts.any?, collapsible: true @@ -97,6 +117,7 @@ class PagesController < ApplicationController key: "outflows_donut", title: "pages.dashboard.outflows_donut.title", partial: "pages/dashboard/outflows_donut", + layout: section_layout("outflows_donut"), locals: { outflows_data: @outflows_data, period: @period }, visible: @accounts.any? && @outflows_data[:categories].present?, collapsible: true @@ -105,6 +126,7 @@ class PagesController < ApplicationController key: "investment_summary", title: "pages.dashboard.investment_summary.title", partial: "pages/dashboard/investment_summary", + layout: section_layout("investment_summary"), locals: { investment_statement: @investment_statement, period: @period }, visible: @accounts.any? && @investment_statement.investment_accounts.any?, collapsible: true @@ -113,6 +135,7 @@ class PagesController < ApplicationController key: "net_worth_chart", title: "pages.dashboard.net_worth_chart.title", partial: "pages/dashboard/net_worth_chart", + layout: section_layout("net_worth_chart"), locals: { balance_sheet: @balance_sheet, period: @period }, visible: @accounts.any?, collapsible: true @@ -121,6 +144,7 @@ class PagesController < ApplicationController key: "balance_sheet", title: "pages.dashboard.balance_sheet.title", partial: "pages/dashboard/balance_sheet", + layout: section_layout("balance_sheet"), locals: { balance_sheet: @balance_sheet }, visible: @accounts.any?, collapsible: true @@ -141,6 +165,22 @@ class PagesController < ApplicationController ordered_sections end + # Resolves a section's layout guardrails, applying the user's height preset + # override (falling back to the deterministic default) for grow widgets. + def section_layout(key) + base = DASHBOARD_SECTION_LAYOUTS.fetch(key, { col_span: "single", grow: false, min_height: 0, width_toggle: false }) + preset = Current.user.dashboard_section_height(key) + preset = DEFAULT_HEIGHT_PRESET unless DASHBOARD_HEIGHT_PRESETS.key?(preset) + + col_span = base[:col_span] + if base[:width_toggle] + user_span = Current.user.dashboard_section_width(key) + col_span = user_span if %w[single full].include?(user_span) + end + + base.merge(col_span: col_span, height_preset: preset, height_px: DASHBOARD_HEIGHT_PRESETS.fetch(preset)) + end + def github_provider Provider::Registry.get_provider(:github) end diff --git a/app/javascript/controllers/dashboard_masonry_controller.js b/app/javascript/controllers/dashboard_masonry_controller.js new file mode 100644 index 000000000..99c2343b1 --- /dev/null +++ b/app/javascript/controllers/dashboard_masonry_controller.js @@ -0,0 +1,82 @@ +import { Controller } from "@hotwired/stimulus"; + +// Packs variable-height dashboard cards into a tight masonry layout by computing +// each card's grid row span from its measured height, letting `grid-auto-flow: +// dense` fill the gaps. Active only when the grid is actually multi-column; in +// single-column mode it clears the spans and normal block flow takes over. +// +// The DOM stays a single flat list of
children, so +// the dashboard-sortable controller (drag / keyboard reorder) is unaffected — +// reordering just moves a node and the CSS re-packs. +export default class extends Controller { + connect() { + this._scheduleLayout = this._scheduleLayout.bind(this); + this._frame = null; + this._resizeObserver = new ResizeObserver(this._scheduleLayout); + + this._cards().forEach((card) => this._resizeObserver.observe(card)); + this._scheduleLayout(); + + // Re-pack after the dashboard turbo frame re-renders (period change, reorder + // save) and on viewport changes that may cross the column breakpoint. + this.element.addEventListener("turbo:frame-load", this._scheduleLayout); + window.addEventListener("resize", this._scheduleLayout); + } + + disconnect() { + this._resizeObserver?.disconnect(); + this.element.removeEventListener("turbo:frame-load", this._scheduleLayout); + window.removeEventListener("resize", this._scheduleLayout); + if (this._frame) cancelAnimationFrame(this._frame); + this._cards().forEach((card) => { + card.style.gridRowEnd = ""; + delete card.dataset.masonrySpan; + }); + } + + _cards() { + return Array.from( + this.element.querySelectorAll(":scope > [data-section-key]"), + ); + } + + _scheduleLayout() { + if (this._frame) cancelAnimationFrame(this._frame); + this._frame = requestAnimationFrame(() => this._layout()); + } + + _layout() { + const styles = getComputedStyle(this.element); + const columns = styles.gridTemplateColumns + .split(" ") + .filter(Boolean).length; + + // Single column: let natural block flow handle it, clear any stale spans. + if (columns < 2) { + this._cards().forEach((card) => { + if (card.dataset.masonrySpan) { + card.style.gridRowEnd = ""; + delete card.dataset.masonrySpan; + } + }); + return; + } + + // The row gap is zeroed in masonry mode (2xl:gap-y-0). We reproduce a uniform + // inter-card gap by padding each card's span with empty tracks equal to the + // column gap. Integer row-spans over a non-zero row gap otherwise overshoot + // by up to one gap's worth of slack, giving visibly uneven vertical gaps. + const rowUnit = Number.parseFloat(styles.gridAutoRows) || 1; + const gap = Number.parseFloat(styles.columnGap) || 0; + const gapTracks = Math.round(gap / rowUnit); + + this._cards().forEach((card) => { + const height = card.getBoundingClientRect().height; + const span = Math.max(1, Math.ceil(height / rowUnit) + gapTracks); + if (card.dataset.masonrySpan !== String(span)) { + card.style.gridRowEnd = `span ${span}`; + card.dataset.masonrySpan = String(span); + } + }); + } +} diff --git a/app/javascript/controllers/dashboard_widget_size_controller.js b/app/javascript/controllers/dashboard_widget_size_controller.js new file mode 100644 index 000000000..57c87cf5d --- /dev/null +++ b/app/javascript/controllers/dashboard_widget_size_controller.js @@ -0,0 +1,110 @@ +import { Controller } from "@hotwired/stimulus"; + +// Per-widget layout picker (mounted on the
menu). Optimistically applies +// the choice for instant feedback, then persists it fire-and-forget — mirroring how +// collapse / reorder already save dashboard preferences: +// +// - Height: sets the `--dash-widget-h` CSS var; the chart redraws via its own +// ResizeObserver and dashboard-masonry re-packs from the height change. +// - Width: toggles the `2xl:col-span-2` class; the CSS grid re-flows and a +// synthetic resize nudges dashboard-masonry to re-pack. +export default class extends Controller { + static values = { sectionKey: String }; + + connect() { + this._closeOnOutsideClick = this._closeOnOutsideClick.bind(this); + document.addEventListener("click", this._closeOnOutsideClick); + } + + disconnect() { + document.removeEventListener("click", this._closeOnOutsideClick); + } + + // Keep Enter/Space/arrow keydowns inside the menu from bubbling to the + // section-level dashboard-sortable handler, which would otherwise hijack them + // to toggle keyboard reorder mode. + stopKeydown(event) { + event.stopPropagation(); + } + + selectHeight(event) { + const { preset, height } = event.currentTarget.dataset; + const section = this._section(); + if (section && height) { + section.style.setProperty("--dash-widget-h", `${height}px`); + } + this._markSelected(event.currentTarget); + this.element.open = false; + this._save({ height: preset }); + } + + selectWidth(event) { + const { colSpan } = event.currentTarget.dataset; + const section = this._section(); + if (section) { + section.classList.toggle("2xl:col-span-2", colSpan === "full"); + } + this._markSelected(event.currentTarget); + this.element.open = false; + this._save({ col_span: colSpan }); + // Width change re-flows the grid; nudge dashboard-masonry to re-pack. + window.dispatchEvent(new Event("resize")); + } + + _section() { + return this.element.closest("[data-section-key]"); + } + + // Move the active state to the clicked segment within its segmented control, + // mirroring DS::SegmentedControl's class + aria-pressed contract. + _markSelected(button) { + const group = button.closest(".segmented-control"); + if (!group) return; + group.querySelectorAll(".segmented-control__segment").forEach((el) => { + const on = el === button; + el.classList.toggle("segmented-control__segment--active", on); + el.setAttribute("aria-pressed", on ? "true" : "false"); + }); + } + + _closeOnOutsideClick(event) { + if (this.element.open && !this.element.contains(event.target)) { + this.element.open = false; + } + } + + async _save(payload) { + const csrfToken = document.querySelector('meta[name="csrf-token"]'); + if (!csrfToken) { + console.error("[Dashboard Widget Size] CSRF token not found."); + return; + } + + try { + const response = await fetch("/dashboard/preferences", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": csrfToken.content, + }, + body: JSON.stringify({ + preferences: { + dashboard_section_layout: { [this.sectionKeyValue]: payload }, + }, + }), + }); + + if (!response.ok) { + console.error( + "[Dashboard Widget Size] Failed to save layout:", + response.status, + ); + } + } catch (error) { + console.error( + "[Dashboard Widget Size] Network error saving layout:", + error, + ); + } + } +} diff --git a/app/models/user.rb b/app/models/user.rb index cf1c257c1..1955a64fb 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -314,6 +314,16 @@ class User < ApplicationRecord preferences&.[]("section_order") || default_dashboard_section_order end + # Per-widget height preset override ("compact" | "auto" | "tall"); nil = use default. + def dashboard_section_height(section_key) + preferences&.dig("dashboard_section_layout", section_key, "height") + end + + # Per-widget column-span override ("single" | "full"); nil = use default. + def dashboard_section_width(section_key) + preferences&.dig("dashboard_section_layout", section_key, "col_span") + end + def update_dashboard_preferences(prefs) # Use pessimistic locking to ensure atomic read-modify-write # This prevents race conditions when multiple sections are collapsed quickly @@ -324,7 +334,9 @@ class User < ApplicationRecord prefs.each do |key, value| if value.is_a?(Hash) updated_prefs[key] ||= {} - updated_prefs[key] = updated_prefs[key].merge(value) + # deep_merge so a partial update of one nested dimension (e.g. a widget's + # col_span) doesn't clobber a sibling dimension (e.g. its height). + updated_prefs[key] = updated_prefs[key].deep_merge(value) else updated_prefs[key] = value end diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index c2fd65fa4..c804f0c1b 100644 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -36,12 +36,14 @@ <% end %> -
gap-6 pb-6 lg:pb-12" data-controller="dashboard-sortable" data-action="dragover->dashboard-sortable#dragOver drop->dashboard-sortable#drop" role="list" aria-label="Dashboard sections"> + <% two_column = Current.user.dashboard_two_column? %> +
gap-6 pb-6 lg:pb-12" data-controller="dashboard-sortable dashboard-masonry" data-action="dragover->dashboard-sortable#dragOver drop->dashboard-sortable#drop" role="list" aria-label="<%= t("pages.dashboard.sections_aria_label") %>"> <% if accessible_accounts.any? %> <% @dashboard_sections.each do |section| %> <% next unless section[:visible] %>
" + style="<%= "--dash-widget-h: #{section[:layout][:height_px]}px;" if section[:layout][:grow] %>" data-dashboard-sortable-target="section" data-section-key="<%= section[:key] %>" data-controller="dashboard-section<%= " cashflow-expand" if section[:key] == "cashflow_sankey" %>" @@ -85,6 +87,57 @@ <%= icon("maximize-2", size: "sm", class: "!w-3.5 !h-3.5") %> <% end %> + <% layout = section[:layout] %> + <% show_width = layout[:width_toggle] && two_column %> + <% if layout[:grow] || show_width %> + + <% end %>