diff --git a/config/initializers/encryption_warning.rb b/config/initializers/encryption_warning.rb
new file mode 100644
index 000000000..31d19b4aa
--- /dev/null
+++ b/config/initializers/encryption_warning.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+# Warn self-hosted operators when ActiveRecord Encryption is NOT configured.
+#
+# This emits a clear startup warning so plaintext-at-rest is never silent.
+require Rails.root.join("lib/active_record_encryption_config").to_s
+
+Rails.application.config.after_initialize do
+ app_mode = Rails.application.config.app_mode
+ if app_mode.self_hosted? && !ActiveRecordEncryptionConfig.explicitly_configured?
+ Rails.logger.warn(<<~WARN)
+ [SECURITY] ActiveRecord Encryption is NOT configured. Sensitive data
+ (API keys, provider/bank tokens, MFA secrets, and PII) are being stored
+ UNENCRYPTED at rest. To enable encryption, set the following keys in your Rails credentials or environment variables:
+ ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY
+ ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY
+ ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT
+ Generate a set with: bin/rails db:encryption:init
+ WARN
+ end
+end
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/en.yml b/config/locales/views/pages/en.yml
index b03062678..9b9ff387b 100644
--- a/config/locales/views/pages/en.yml
+++ b/config/locales/views/pages/en.yml
@@ -41,8 +41,18 @@ en:
welcome: "Welcome back, %{name}"
subtitle: "Here's what's happening with your finances"
new: "New"
+ sections_aria_label: "Dashboard sections"
drag_to_reorder: "Drag to reorder section"
toggle_section: "Toggle section visibility"
+ widget_size:
+ label: "Adjust size"
+ width_label: "Width"
+ half: "Half"
+ full: "Full"
+ height_label: "Height"
+ compact: "Compact"
+ auto: "Auto"
+ tall: "Tall"
net_worth_chart:
data_not_available: Data not available for the selected period
title: Net Worth
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/en.yml b/config/locales/views/settings/en.yml
index 2d9a98651..0280986d2 100644
--- a/config/locales/views/settings/en.yml
+++ b/config/locales/views/settings/en.yml
@@ -112,7 +112,7 @@ en:
dashboard_title: Dashboard
dashboard_subtitle: Customize how the dashboard is displayed
dashboard_two_column_title: Two-column layout
- dashboard_two_column_description: Display dashboard widgets in two columns on large screens. When off, widgets stack in a single column.
+ dashboard_two_column_description: Display dashboard widgets in two columns on large screens, with per-widget width and height controls in each widget's header. When off, widgets stack in a single column.
split_grouped_title: Group split transactions
split_grouped_description: Show split transactions grouped under their parent in the transaction list. When off, split children appear as individual rows.
preferences:
@@ -213,6 +213,14 @@ en:
securities:
show:
page_title: Security
+ encryption_warning:
+ title: Encryption keys missing
+ intro: "Sensitive data (API keys, provider tokens, MFA secrets, and PII) are being stored unencrypted at rest. To enable encryption, set the following keys in your environment variables or Rails credentials:"
+ keys:
+ - ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY
+ - ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY
+ - ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT
+ generate: "Generate a set with: bin/rails db:encryption:init"
mfa_title: Two-Factor Authentication
mfa_description: Add an extra layer of security to your account by requiring a code from your authenticator app when signing in
enable_mfa: Enable 2FA
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: 编辑账户详情
diff --git a/mobile/lib/screens/login_screen.dart b/mobile/lib/screens/login_screen.dart
index efff02d6f..cee517e41 100644
--- a/mobile/lib/screens/login_screen.dart
+++ b/mobile/lib/screens/login_screen.dart
@@ -5,6 +5,7 @@ import 'package:url_launcher/url_launcher.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../providers/auth_provider.dart';
import '../services/api_config.dart';
+import '../widgets/sure_button.dart';
import 'backend_config_screen.dart';
class LoginScreen extends StatefulWidget {
@@ -411,17 +412,12 @@ class _LoginScreenState extends State
{
// Login Button
Consumer(
builder: (context, authProvider, _) {
- return ElevatedButton(
- onPressed:
- authProvider.isLoading ? null : _handleLogin,
- child: authProvider.isLoading
- ? const SizedBox(
- height: 20,
- width: 20,
- child:
- CircularProgressIndicator(strokeWidth: 2),
- )
- : const Text('Sign In'),
+ return SureButton(
+ label: 'Sign In',
+ size: SureButtonSize.lg,
+ fullWidth: true,
+ loading: authProvider.isLoading,
+ onPressed: _handleLogin,
);
},
),
@@ -451,23 +447,20 @@ class _LoginScreenState extends State {
// Google Sign-In button
Consumer(
builder: (context, authProvider, _) {
- return OutlinedButton.icon(
- onPressed: authProvider.isLoading
- ? null
- : () =>
- authProvider.startSsoLogin('google_oauth2'),
- icon: SvgPicture.asset(
+ return SureButton(
+ label: 'Sign in with Google',
+ variant: SureButtonVariant.outline,
+ size: SureButtonSize.lg,
+ fullWidth: true,
+ leading: SvgPicture.asset(
'assets/images/google_g_logo.svg',
width: 18,
height: 18,
),
- label: const Text('Sign in with Google'),
- style: OutlinedButton.styleFrom(
- minimumSize: const Size(double.infinity, 50),
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(12),
- ),
- ),
+ onPressed: authProvider.isLoading
+ ? null
+ : () =>
+ authProvider.startSsoLogin('google_oauth2'),
);
},
),
@@ -513,10 +506,17 @@ class _LoginScreenState extends State {
const SizedBox(height: 12),
// API Key Login Button
- TextButton.icon(
- onPressed: _showApiKeyDialog,
- icon: const Icon(Icons.vpn_key_outlined, size: 18),
- label: const Text('API-Key Login'),
+ Consumer(
+ builder: (context, authProvider, _) {
+ return SureButton(
+ label: 'API-Key Login',
+ variant: SureButtonVariant.ghost,
+ onPressed:
+ authProvider.isLoading ? null : _showApiKeyDialog,
+ leading:
+ const Icon(Icons.vpn_key_outlined, size: 18),
+ );
+ },
),
],
),
diff --git a/mobile/lib/widgets/sure_button.dart b/mobile/lib/widgets/sure_button.dart
new file mode 100644
index 000000000..0b67da858
--- /dev/null
+++ b/mobile/lib/widgets/sure_button.dart
@@ -0,0 +1,285 @@
+import 'package:flutter/cupertino.dart' show CupertinoActivityIndicator;
+import 'package:flutter/material.dart';
+
+import '../theme/sure_colors.dart';
+import '../theme/sure_tokens.dart';
+
+/// Sure design-system button variants, mirroring the web `DS::Button`
+/// (`DS::Buttonish::VARIANTS`).
+enum SureButtonVariant { primary, secondary, destructive, outline, ghost }
+
+/// Button sizes, mirroring the web `DS::Buttonish::SIZES` (sm/md/lg ≈ 28/36/48).
+enum SureButtonSize { sm, md, lg }
+
+/// Sure design-system button — a custom, non-Material control mirroring the web
+/// `DS::Button`: tokenized variant colors, `font-medium` label, sizes, and a
+/// flat custom press feedback (no Material ripple). `onPressed: null` (or
+/// [loading]) renders a disabled button.
+///
+/// Colors resolve from the active [SureColors] palette, so the button is
+/// brightness-aware and stays in lockstep with `sure.tokens.json`.
+class SureButton extends StatefulWidget {
+ final String label;
+
+ /// Tap handler. When null the button is disabled (50% opacity, no taps).
+ final VoidCallback? onPressed;
+
+ final SureButtonVariant variant;
+ final SureButtonSize size;
+
+ /// Optional leading widget (e.g. a `SureIcon`), kept decoupled so the button
+ /// doesn't depend on any specific icon implementation.
+ final Widget? leading;
+
+ /// Stretch to the available width (mirrors `full_width`).
+ final bool fullWidth;
+
+ /// Show an adaptive spinner in place of the leading slot and disable taps.
+ final bool loading;
+
+ const SureButton({
+ super.key,
+ required this.label,
+ required this.onPressed,
+ this.variant = SureButtonVariant.primary,
+ this.size = SureButtonSize.md,
+ this.leading,
+ this.fullWidth = false,
+ this.loading = false,
+ });
+
+ @override
+ State createState() => _SureButtonState();
+}
+
+class _SureButtonState extends State {
+ bool _pressed = false;
+ bool _focused = false;
+
+ bool get _enabled => widget.onPressed != null && !widget.loading;
+
+ @override
+ void didUpdateWidget(covariant SureButton oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ // If the button is disabled mid-press, onTapUp/onTapCancel never fire — so
+ // clear the pressed state here to avoid it sticking on once re-enabled.
+ if (!_enabled) {
+ _pressed = false;
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final palette = SureColors.of(context).palette;
+ final style = _SureButtonStyle.resolve(widget.variant, palette);
+ final metrics = _SureButtonMetrics.resolve(widget.size);
+
+ final background = _pressed && _enabled ? style.pressedBackground : style.background;
+
+ final label = Text(
+ widget.label,
+ style: TextStyle(
+ color: style.foreground,
+ fontSize: metrics.fontSize,
+ fontWeight: FontWeight.w500,
+ ),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ );
+
+ // Build the spinner per-platform so the foreground tint is honored on both:
+ // CircularProgressIndicator.adaptive renders a CupertinoActivityIndicator on
+ // Apple platforms, which ignores `valueColor`, so pass `color` to it directly.
+ final platform = Theme.of(context).platform;
+ final isCupertino =
+ platform == TargetPlatform.iOS || platform == TargetPlatform.macOS;
+ final leading = widget.loading
+ ? SizedBox(
+ width: metrics.fontSize,
+ height: metrics.fontSize,
+ child: isCupertino
+ ? CupertinoActivityIndicator(
+ color: style.foreground,
+ radius: metrics.fontSize / 2,
+ )
+ : CircularProgressIndicator(
+ strokeWidth: 2,
+ valueColor: AlwaysStoppedAnimation(style.foreground),
+ ),
+ )
+ : widget.leading;
+
+ final content = Row(
+ mainAxisSize: widget.fullWidth ? MainAxisSize.max : MainAxisSize.min,
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ if (leading != null) ...[leading, const SizedBox(width: 8)],
+ // Flex only when full-width (bounded). A bare Flexible in a min-size Row
+ // asserts under unbounded horizontal constraints, so an inline button
+ // passes the self-sizing label directly.
+ if (widget.fullWidth) Flexible(child: label) else label,
+ ],
+ );
+
+ final button = AnimatedContainer(
+ duration: const Duration(milliseconds: 80),
+ constraints: BoxConstraints(minHeight: metrics.height),
+ padding: EdgeInsets.symmetric(horizontal: metrics.horizontalPadding),
+ decoration: BoxDecoration(
+ color: background,
+ borderRadius: BorderRadius.circular(metrics.radius),
+ border: style.border == null
+ ? null
+ : Border.all(color: style.border!),
+ // Keyboard/switch-control focus ring (drawn as a non-displacing ring so
+ // it doesn't shift layout). borderPrimary is brightness-aware.
+ boxShadow: _focused
+ ? [
+ BoxShadow(
+ color: palette.borderPrimary,
+ blurRadius: 0,
+ spreadRadius: 2,
+ ),
+ ]
+ : null,
+ ),
+ child: Center(widthFactor: widget.fullWidth ? null : 1.0, child: content),
+ );
+
+ return Semantics(
+ button: true,
+ enabled: _enabled,
+ label: widget.loading ? '${widget.label}, loading' : widget.label,
+ child: FocusableActionDetector(
+ enabled: _enabled,
+ mouseCursor:
+ _enabled ? SystemMouseCursors.click : SystemMouseCursors.basic,
+ onShowFocusHighlight: (value) => setState(() => _focused = value),
+ actions: >{
+ ActivateIntent: CallbackAction(
+ onInvoke: (_) {
+ if (_enabled) widget.onPressed?.call();
+ return null;
+ },
+ ),
+ },
+ child: Opacity(
+ opacity: _enabled ? 1.0 : 0.5,
+ child: GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ // Keep all tap callbacks present (a stable gesture arena) and gate
+ // on `_enabled` inside them. Swapping them to null when the button
+ // is disabled mid-press would dispose the recognizer during the
+ // rebuild and fire onTapCancel -> setState() during build.
+ onTapDown: (_) {
+ if (_enabled) setState(() => _pressed = true);
+ },
+ onTapUp: (_) {
+ if (_pressed) setState(() => _pressed = false);
+ },
+ onTapCancel: () {
+ if (_pressed) setState(() => _pressed = false);
+ },
+ onTap: () {
+ if (_enabled) widget.onPressed?.call();
+ },
+ child: button,
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _SureButtonStyle {
+ const _SureButtonStyle({
+ required this.background,
+ required this.pressedBackground,
+ required this.foreground,
+ this.border,
+ });
+
+ final Color background;
+ final Color pressedBackground;
+ final Color foreground;
+ final Color? border;
+
+ static _SureButtonStyle resolve(
+ SureButtonVariant variant,
+ SureTokenPalette p,
+ ) {
+ switch (variant) {
+ case SureButtonVariant.primary:
+ return _SureButtonStyle(
+ background: p.buttonPrimary,
+ pressedBackground: p.buttonPrimaryHover,
+ foreground: p.textInverse,
+ );
+ case SureButtonVariant.destructive:
+ return _SureButtonStyle(
+ background: p.buttonDestructive,
+ pressedBackground: p.buttonDestructiveHover,
+ foreground: p.textInverse,
+ );
+ case SureButtonVariant.secondary:
+ return _SureButtonStyle(
+ background: p.surfaceInset,
+ pressedBackground: p.surfaceInsetHover,
+ foreground: p.textPrimary,
+ );
+ case SureButtonVariant.outline:
+ return _SureButtonStyle(
+ background: const Color(0x00000000),
+ pressedBackground: p.surfaceHover,
+ foreground: p.textPrimary,
+ border: p.borderSecondary,
+ );
+ case SureButtonVariant.ghost:
+ return _SureButtonStyle(
+ background: const Color(0x00000000),
+ pressedBackground: p.surfaceHover,
+ foreground: p.textPrimary,
+ );
+ }
+ }
+}
+
+class _SureButtonMetrics {
+ const _SureButtonMetrics({
+ required this.height,
+ required this.horizontalPadding,
+ required this.fontSize,
+ required this.radius,
+ });
+
+ final double height;
+ final double horizontalPadding;
+ final double fontSize;
+ final double radius;
+
+ static _SureButtonMetrics resolve(SureButtonSize size) {
+ switch (size) {
+ case SureButtonSize.sm:
+ return const _SureButtonMetrics(
+ height: 28,
+ horizontalPadding: 12,
+ fontSize: 14,
+ radius: SureTokens.radiusMd,
+ );
+ case SureButtonSize.md:
+ return const _SureButtonMetrics(
+ height: 36,
+ horizontalPadding: 16,
+ fontSize: 14,
+ radius: SureTokens.radiusLg,
+ );
+ case SureButtonSize.lg:
+ return const _SureButtonMetrics(
+ height: 48,
+ horizontalPadding: 20,
+ fontSize: 16,
+ radius: SureTokens.radiusLg,
+ );
+ }
+ }
+}
diff --git a/mobile/test/widgets/sure_button_test.dart b/mobile/test/widgets/sure_button_test.dart
new file mode 100644
index 000000000..df4adfc2c
--- /dev/null
+++ b/mobile/test/widgets/sure_button_test.dart
@@ -0,0 +1,208 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:sure_mobile/theme/sure_theme.dart';
+import 'package:sure_mobile/theme/sure_tokens.dart';
+import 'package:sure_mobile/widgets/sure_button.dart';
+
+void main() {
+ Future pump(WidgetTester tester, Widget child) {
+ return tester.pumpWidget(
+ MaterialApp(
+ theme: SureTheme.light,
+ home: Scaffold(body: Center(child: child)),
+ ),
+ );
+ }
+
+ BoxDecoration decoration(WidgetTester tester) =>
+ tester.widget(find.byType(AnimatedContainer)).decoration
+ as BoxDecoration;
+
+ testWidgets('primary uses the button-primary token + inverse label', (tester) async {
+ await pump(
+ tester,
+ SureButton(label: 'Save', onPressed: () {}),
+ );
+ expect(decoration(tester).color, SureTokens.light.buttonPrimary);
+ final label = tester.widget(find.text('Save'));
+ expect(label.style?.color, SureTokens.light.textInverse);
+ expect(label.style?.fontWeight, FontWeight.w500);
+ });
+
+ testWidgets('destructive uses the destructive token', (tester) async {
+ await pump(
+ tester,
+ SureButton(
+ label: 'Delete',
+ variant: SureButtonVariant.destructive,
+ onPressed: () {},
+ ),
+ );
+ expect(decoration(tester).color, SureTokens.light.buttonDestructive);
+ });
+
+ testWidgets('secondary uses the inset-surface token', (tester) async {
+ await pump(
+ tester,
+ SureButton(
+ label: 'More',
+ variant: SureButtonVariant.secondary,
+ onPressed: () {},
+ ),
+ );
+ expect(decoration(tester).color, SureTokens.light.surfaceInset);
+ });
+
+ testWidgets('outline is transparent with a border and primary text',
+ (tester) async {
+ await pump(
+ tester,
+ SureButton(
+ label: 'Outline',
+ variant: SureButtonVariant.outline,
+ onPressed: () {},
+ ),
+ );
+ final deco = decoration(tester);
+ expect(deco.color, const Color(0x00000000));
+ expect((deco.border as Border).top.color, SureTokens.light.borderSecondary);
+ expect(
+ tester.widget(find.text('Outline')).style?.color,
+ SureTokens.light.textPrimary,
+ );
+ });
+
+ testWidgets('ghost is transparent with no border', (tester) async {
+ await pump(
+ tester,
+ SureButton(
+ label: 'Ghost',
+ variant: SureButtonVariant.ghost,
+ onPressed: () {},
+ ),
+ );
+ final deco = decoration(tester);
+ expect(deco.color, const Color(0x00000000));
+ expect(deco.border, isNull);
+ });
+
+ testWidgets('renders in an unbounded-width Row without asserting',
+ (tester) async {
+ // Regression: a bare Flexible in the label Row used to throw under
+ // unbounded horizontal constraints; an inline (non-full-width) button must
+ // self-size instead.
+ await pump(
+ tester,
+ Row(children: [SureButton(label: 'Inline', onPressed: () {})]),
+ );
+ expect(tester.takeException(), isNull);
+ expect(find.text('Inline'), findsOneWidget);
+ });
+
+ testWidgets('clears the pressed highlight if disabled mid-press',
+ (tester) async {
+ // Regression: if disabled while pressed, onTapUp/onTapCancel never fire, so
+ // didUpdateWidget must reset _pressed (otherwise the hover bg sticks once
+ // the button is re-enabled).
+ Widget build(bool loading) => MaterialApp(
+ theme: SureTheme.light,
+ home: Scaffold(
+ body: Center(
+ child: SureButton(
+ key: const ValueKey('btn'),
+ label: 'Go',
+ loading: loading,
+ onPressed: () {},
+ ),
+ ),
+ ),
+ );
+
+ await tester.pumpWidget(build(false));
+ final gesture =
+ await tester.startGesture(tester.getCenter(find.byType(SureButton)));
+ await tester.pump(); // onTapDown -> _pressed = true
+ await tester.pumpWidget(build(true)); // disabled mid-press -> reset
+ await tester.pump();
+ await tester.pumpWidget(build(false)); // re-enabled
+ await tester.pump();
+ // Background must be the base token, not the pressed (hover) token.
+ expect(decoration(tester).color, SureTokens.light.buttonPrimary);
+ await gesture.up();
+ });
+
+ testWidgets('tap fires onPressed when enabled', (tester) async {
+ var taps = 0;
+ await pump(tester, SureButton(label: 'Go', onPressed: () => taps++));
+ await tester.tap(find.byType(SureButton));
+ expect(taps, 1);
+ });
+
+ testWidgets('null onPressed disables taps and dims the button', (tester) async {
+ await pump(
+ tester,
+ const SureButton(label: 'Disabled', onPressed: null),
+ );
+ // Tapping a disabled button must not throw, and it stays dimmed.
+ await tester.tap(find.byType(SureButton));
+ expect(tester.widget(find.byType(Opacity)).opacity, 0.5);
+ });
+
+ testWidgets('loading shows a spinner and blocks taps', (tester) async {
+ var taps = 0;
+ await pump(
+ tester,
+ SureButton(label: 'Saving', onPressed: () => taps++, loading: true),
+ );
+ expect(find.byType(CircularProgressIndicator), findsOneWidget);
+ await tester.tap(find.byType(SureButton));
+ expect(taps, 0);
+ });
+
+ testWidgets('activates via the keyboard (focus + Enter)', (tester) async {
+ var taps = 0;
+ await pump(tester, SureButton(label: 'Go', onPressed: () => taps++));
+ await tester.sendKeyEvent(LogicalKeyboardKey.tab);
+ await tester.pumpAndSettle();
+ await tester.sendKeyEvent(LogicalKeyboardKey.enter);
+ await tester.pumpAndSettle();
+ expect(taps, 1);
+ });
+
+ testWidgets('fullWidth fills the available width', (tester) async {
+ await pump(
+ tester,
+ ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 300),
+ child: SureButton(label: 'Wide', fullWidth: true, onPressed: () {}),
+ ),
+ );
+ expect(tester.getSize(find.byType(SureButton)).width, 300);
+ });
+
+ testWidgets('non-fullWidth hugs its content', (tester) async {
+ await pump(
+ tester,
+ ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 300),
+ child: SureButton(label: 'Hi', onPressed: () {}),
+ ),
+ );
+ expect(tester.getSize(find.byType(SureButton)).width, lessThan(300));
+ });
+
+ testWidgets('size maps to the canonical min-height', (tester) async {
+ await pump(
+ tester,
+ SureButton(label: 'Big', size: SureButtonSize.lg, onPressed: () {}),
+ );
+ expect(
+ tester
+ .widget(find.byType(AnimatedContainer))
+ .constraints
+ ?.minHeight,
+ 48,
+ );
+ });
+}
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
diff --git a/test/controllers/pages_controller_test.rb b/test/controllers/pages_controller_test.rb
index 8dcef79b9..cb3d451b4 100644
--- a/test/controllers/pages_controller_test.rb
+++ b/test/controllers/pages_controller_test.rb
@@ -14,6 +14,35 @@ class PagesControllerTest < ActionDispatch::IntegrationTest
assert_response :ok
end
+ test "update_preferences persists dashboard section layout height" do
+ patch "/dashboard/preferences", params: {
+ preferences: { dashboard_section_layout: { net_worth_chart: { height: "compact" } } }
+ }, as: :json
+
+ assert_response :ok
+ assert_equal "compact", @user.reload.dashboard_section_height("net_worth_chart")
+ end
+
+ test "update_preferences persists dashboard section width" do
+ patch "/dashboard/preferences", params: {
+ preferences: { dashboard_section_layout: { cashflow_sankey: { col_span: "single" } } }
+ }, as: :json
+
+ assert_response :ok
+ assert_equal "single", @user.reload.dashboard_section_width("cashflow_sankey")
+ end
+
+ test "update_preferences ignores malformed dashboard_section_layout without erroring" do
+ previous_height = @user.reload.dashboard_section_height("net_worth_chart")
+
+ patch "/dashboard/preferences", params: {
+ preferences: { dashboard_section_layout: "not-a-hash" }
+ }, as: :json
+
+ assert_response :ok
+ assert_equal previous_height, @user.reload.dashboard_section_height("net_worth_chart")
+ end
+
test "dashboard memoizes income statement period totals while rendering" do
income_statement = IncomeStatement.new(@family)
IncomeStatement.stubs(:new).returns(income_statement)
diff --git a/test/controllers/settings/securities_controller_test.rb b/test/controllers/settings/securities_controller_test.rb
new file mode 100644
index 000000000..26c12306d
--- /dev/null
+++ b/test/controllers/settings/securities_controller_test.rb
@@ -0,0 +1,34 @@
+require "test_helper"
+
+class Settings::SecuritiesControllerTest < ActionDispatch::IntegrationTest
+ setup { sign_in users(:family_admin) }
+
+ test "shows encryption warning when self-hosted and encryption is not configured" do
+ Rails.configuration.stubs(:app_mode).returns("self_hosted".inquiry)
+ ActiveRecordEncryptionConfig.stubs(:explicitly_configured?).returns(false)
+
+ get settings_security_url
+
+ assert_response :success
+ assert_includes response.body, I18n.t("settings.securities.show.encryption_warning.title")
+ end
+
+ test "hides encryption warning when encryption is configured" do
+ Rails.configuration.stubs(:app_mode).returns("self_hosted".inquiry)
+ ActiveRecordEncryptionConfig.stubs(:explicitly_configured?).returns(true)
+
+ get settings_security_url
+
+ assert_response :success
+ assert_not_includes response.body, I18n.t("settings.securities.show.encryption_warning.title")
+ end
+
+ test "does not show encryption warning in managed mode" do
+ Rails.configuration.stubs(:app_mode).returns("managed".inquiry)
+
+ get settings_security_url
+
+ assert_response :success
+ assert_not_includes response.body, I18n.t("settings.securities.show.encryption_warning.title")
+ end
+end
diff --git a/test/interfaces/syncable_interface_test.rb b/test/interfaces/syncable_interface_test.rb
index df1420529..a972f375d 100644
--- a/test/interfaces/syncable_interface_test.rb
+++ b/test/interfaces/syncable_interface_test.rb
@@ -18,6 +18,18 @@ module SyncableInterfaceTest
@syncable.perform_sync(mock_sync)
end
+ test "sync_later does not enqueue SyncJob while a surrounding transaction is still open" do
+ job_enqueued_mid_transaction = false
+
+ ActiveRecord::Base.transaction do
+ @syncable.sync_later
+ job_enqueued_mid_transaction = queue_adapter.enqueued_jobs.any? { |j| j[:job] == SyncJob }
+ end
+
+ assert_not job_enqueued_mid_transaction, "SyncJob was enqueued inside an open transaction (GlobalID race)"
+ assert_enqueued_with(job: SyncJob)
+ end
+
test "second sync request widens existing pending window" do
later_start = 2.days.ago.to_date
first_sync = @syncable.sync_later(window_start_date: later_start, window_end_date: later_start)
diff --git a/test/jobs/application_job_test.rb b/test/jobs/application_job_test.rb
new file mode 100644
index 000000000..75fffcb3d
--- /dev/null
+++ b/test/jobs/application_job_test.rb
@@ -0,0 +1,36 @@
+require "test_helper"
+
+class ApplicationJobTest < ActiveJob::TestCase
+ # Throwaway subclass used only to exercise ApplicationJob's enqueue policy
+ # without depending on any real job's side effects.
+ class CanaryJob < ApplicationJob
+ def perform; end
+ end
+
+ test "defers enqueue until the surrounding transaction commits" do
+ enqueued_mid_transaction = nil
+
+ ActiveRecord::Base.transaction do
+ CanaryJob.perform_later
+ enqueued_mid_transaction = enqueued_jobs.any? { |job| job[:job] == CanaryJob }
+ end
+
+ assert_not enqueued_mid_transaction, "job was enqueued before the transaction committed"
+ assert_enqueued_with job: CanaryJob
+ end
+
+ test "drops the enqueue when the surrounding transaction rolls back" do
+ assert_no_enqueued_jobs do
+ ActiveRecord::Base.transaction do
+ CanaryJob.perform_later
+ raise ActiveRecord::Rollback
+ end
+ end
+ end
+
+ test "enqueues immediately when there is no surrounding transaction" do
+ assert_enqueued_with job: CanaryJob do
+ CanaryJob.perform_later
+ end
+ end
+end
diff --git a/test/jobs/destroy_job_test.rb b/test/jobs/destroy_job_test.rb
new file mode 100644
index 000000000..54633495e
--- /dev/null
+++ b/test/jobs/destroy_job_test.rb
@@ -0,0 +1,32 @@
+require "test_helper"
+require "ostruct"
+
+class DestroyJobTest < ActiveJob::TestCase
+ test "destroys the model" do
+ model = mock
+ model.expects(:destroy).once
+
+ DestroyJob.perform_now(model)
+ end
+
+ test "resets scheduled_for_deletion when the destroy fails" do
+ model = OpenStruct.new(scheduled_for_deletion: true)
+ model.stubs(:destroy).raises(ActiveRecord::RecordNotDestroyed.new("nope"))
+ model.expects(:update!).with(scheduled_for_deletion: false).once
+
+ DestroyJob.perform_now(model)
+ end
+
+ test "inherits the deferred-enqueue policy from ApplicationJob" do
+ account = accounts(:depository)
+ enqueued_mid_transaction = nil
+
+ ActiveRecord::Base.transaction do
+ DestroyJob.perform_later(account)
+ enqueued_mid_transaction = enqueued_jobs.any? { |job| job[:job] == DestroyJob }
+ end
+
+ assert_not enqueued_mid_transaction, "DestroyJob enqueued before the transaction committed"
+ assert_enqueued_with job: DestroyJob
+ end
+end
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!
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
diff --git a/test/models/rule/action_test.rb b/test/models/rule/action_test.rb
index c6370653a..6e115be67 100644
--- a/test/models/rule/action_test.rb
+++ b/test/models/rule/action_test.rb
@@ -38,6 +38,21 @@ class Rule::ActionTest < ActiveSupport::TestCase
end
end
+ test "set_transaction_category overrides locked category when ignore_attribute_locks" do
+ # Explicit "Re-apply" from the rules UI passes ignore_attribute_locks: true (issue #2051)
+ @txn1.lock_attr!(:category_id)
+
+ action = Rule::Action.new(
+ rule: @transaction_rule,
+ action_type: "set_transaction_category",
+ value: @grocery_category.id
+ )
+
+ action.apply(@rule_scope, ignore_attribute_locks: true)
+
+ assert_equal @grocery_category.id, @txn1.reload.category_id
+ end
+
test "set_transaction_tags" do
tag = @family.tags.create!(name: "Rule test tag")
@@ -146,6 +161,22 @@ class Rule::ActionTest < ActiveSupport::TestCase
end
end
+ test "set_transaction_name overrides locked name when ignore_attribute_locks" do
+ # Explicit "Re-apply" from the rules UI passes ignore_attribute_locks: true (issue #2051)
+ new_name = "Renamed Transaction"
+ @txn1.entry.lock_attr!(:name)
+
+ action = Rule::Action.new(
+ rule: @transaction_rule,
+ action_type: "set_transaction_name",
+ value: new_name
+ )
+
+ action.apply(@rule_scope, ignore_attribute_locks: true)
+
+ assert_equal new_name, @txn1.reload.entry.name
+ end
+
test "set_investment_activity_label" do
# Does not modify transactions that are locked (user edited them)
@txn1.lock_attr!(:investment_activity_label)
diff --git a/test/models/snaptrade_account_test.rb b/test/models/snaptrade_account_test.rb
index 48efc0f30..68172bde3 100644
--- a/test/models/snaptrade_account_test.rb
+++ b/test/models/snaptrade_account_test.rb
@@ -1,6 +1,8 @@
require "test_helper"
class SnaptradeAccountTest < ActiveSupport::TestCase
+ include ActiveJob::TestHelper
+
setup do
@family_a = families(:dylan_family)
@family_b = families(:empty)
@@ -71,4 +73,32 @@ class SnaptradeAccountTest < ActiveSupport::TestCase
)
end
end
+
+ # Regression: the after_destroy callback enqueues SnaptradeConnectionCleanupJob,
+ # which references the account/item by id. If it is enqueued before the destroy
+ # transaction commits (Rails 8.1's immediate-enqueue default), a worker can run
+ # before COMMIT: its "do other accounts still share this authorization?" guard
+ # then sees the not-yet-deleted row, skips the provider call, and leaks the
+ # SnapTrade connection. Enqueuing must be deferred until commit.
+ test "connection cleanup job is deferred until the destroy transaction commits" do
+ account = SnaptradeAccount.create!(
+ snaptrade_item: @item_a,
+ snaptrade_account_id: "cleanup_uuid",
+ snaptrade_authorization_id: "auth_cleanup",
+ name: "Brokerage",
+ currency: "USD",
+ current_balance: 1000
+ )
+
+ enqueued_mid_transaction = nil
+
+ ActiveRecord::Base.transaction do
+ account.destroy!
+ enqueued_mid_transaction = enqueued_jobs.any? { |job| job[:job] == SnaptradeConnectionCleanupJob }
+ end
+
+ assert_not enqueued_mid_transaction,
+ "SnaptradeConnectionCleanupJob was enqueued before the destroy committed (would race COMMIT and leak the provider connection)"
+ assert_enqueued_with job: SnaptradeConnectionCleanupJob
+ end
end
diff --git a/test/models/user_test.rb b/test/models/user_test.rb
index 5139fff09..60825eff9 100644
--- a/test/models/user_test.rb
+++ b/test/models/user_test.rb
@@ -510,6 +510,45 @@ class UserTest < ActiveSupport::TestCase
assert_equal new_order, @user.dashboard_section_order
end
+ test "dashboard_section_height returns stored preset or nil" do
+ @user.update!(preferences: {})
+ assert_nil @user.dashboard_section_height("net_worth_chart")
+
+ @user.update_dashboard_preferences({
+ "dashboard_section_layout" => { "net_worth_chart" => { "height" => "tall" } }
+ })
+ @user.reload
+
+ assert_equal "tall", @user.dashboard_section_height("net_worth_chart")
+ assert_nil @user.dashboard_section_height("balance_sheet")
+ end
+
+ test "dashboard_section_width returns stored col_span or nil" do
+ @user.update!(preferences: {})
+ assert_nil @user.dashboard_section_width("cashflow_sankey")
+
+ @user.update_dashboard_preferences({
+ "dashboard_section_layout" => { "cashflow_sankey" => { "col_span" => "single" } }
+ })
+
+ assert_equal "single", @user.reload.dashboard_section_width("cashflow_sankey")
+ end
+
+ test "dashboard_section_layout merges width and height without clobbering" do
+ @user.update!(preferences: {})
+
+ @user.update_dashboard_preferences({
+ "dashboard_section_layout" => { "net_worth_chart" => { "height" => "tall" } }
+ })
+ @user.update_dashboard_preferences({
+ "dashboard_section_layout" => { "net_worth_chart" => { "col_span" => "full" } }
+ })
+ @user.reload
+
+ assert_equal "tall", @user.dashboard_section_height("net_worth_chart")
+ assert_equal "full", @user.dashboard_section_width("net_worth_chart")
+ end
+
test "handles empty preferences gracefully for dashboard methods" do
@user.update!(preferences: {})