Merge branch 'we-promise:main' into Transfer-charges

This commit is contained in:
maverick
2026-06-16 11:57:07 +05:30
committed by GitHub
99 changed files with 2706 additions and 154 deletions

View File

@@ -1,4 +1,4 @@
ARG RUBY_VERSION=3.4.7
ARG RUBY_VERSION=3.4.9
FROM ruby:${RUBY_VERSION}-slim-bookworm
ENV DEBIAN_FRONTEND=noninteractive

View File

@@ -1 +1 @@
3.4.7
3.4.9

View File

@@ -63,6 +63,15 @@ Reviewers escalate violations of (2)(3) to close/rewrite; (1) and (4) are req
If you need to add a new securities price provider (Tiingo, EODHD, Binance-style crypto, etc.), see [adding-a-securities-provider.md](./docs/llm-guides/adding-a-securities-provider.md) for the full walkthrough — provider class, registry wiring, MIC handling, settings UI, locales, and tests.
## Debug Logging for Provider Syncs
When a provider sync/import path hits a recoverable error or suspicious partial response that support may need to inspect later, prefer `DebugLogEntry.capture(...)` over `Rails.logger.*`.
- Record support-relevant diagnostics in the debug log so they surface in the super-admin-friendly `/settings/debug` UI.
- Include `category`, `level`, `message`, `source`, `provider_key`, and useful structured `metadata`.
- Attach `family` and `account_provider` when available so support can filter and trace the affected connection.
- Reserve raw Rails logging for low-value local noise; anything operators may need should go to the debug log.
## Providers: Pending Transactions and FX Metadata (SimpleFIN/Plaid/Lunchflow)
- Pending detection

View File

@@ -132,6 +132,12 @@ Sidekiq handles asynchronous tasks:
- AI chat responses (`AssistantResponseJob`)
- Scheduled maintenance via sidekiq-cron
### Debug Logging for Provider Syncs
- Prefer `DebugLogEntry.capture(...)` over `Rails.logger.*` for provider sync/import failures, partial responses, and other support-relevant diagnostics.
- Record support-relevant incidents in the super-admin `/settings/debug` UI rather than leaving them only in raw application logs.
- Include `category`, `level`, `message`, `source`, `provider_key`, and structured `metadata`.
- Attach `family` and `account_provider` whenever possible so support can filter to the affected provider connection.
### Frontend Architecture
- **Hotwire Stack**: Turbo + Stimulus for reactive UI without heavy JavaScript
- **ViewComponents**: Reusable UI components in `app/components/`

View File

@@ -1,7 +1,7 @@
# syntax = docker/dockerfile:1
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile
ARG RUBY_VERSION=3.4.7
ARG RUBY_VERSION=3.4.9
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base
# Rails app lives here

View File

@@ -3,7 +3,7 @@
# Preview Dockerfile for Cloudflare Containers
# Includes PostgreSQL and Redis for self-contained development testing
ARG RUBY_VERSION=3.4.7
ARG RUBY_VERSION=3.4.9
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base
WORKDIR /rails

View File

@@ -963,7 +963,7 @@ DEPENDENCIES
webmock
RUBY VERSION
ruby 3.4.7p58
ruby 3.4.9p82
BUNDLED WITH
2.6.7

View File

@@ -33,7 +33,7 @@
<% end %>
<% end %>
<div class="mt-2">
<%= tag.div class: body_class.presence do %>
<%= content %>
</div>
<% end %>
<% end %>

View File

@@ -3,7 +3,7 @@ class DS::Disclosure < DesignSystemComponent
VARIANTS = %i[default card card_inset inline].freeze
attr_reader :title, :align, :open, :variant, :summary_class_override, :opts
attr_reader :title, :align, :open, :variant, :summary_class_override, :body_class, :opts
# `:default` — bg-surface summary, no chrome on the `<details>`. Use
# for inline expanders that sit inside a parent card (the summary
@@ -28,12 +28,18 @@ class DS::Disclosure < DesignSystemComponent
# In card / inline variants, callers should pass their own
# `summary_content` slot; the built-in title rendering assumes the
# `:default` shape.
def initialize(title: nil, align: "right", open: false, variant: :default, summary_class: nil, **opts)
# `body_class:` styles the wrapper around the disclosure body. Defaults
# to `mt-2` (the standard gap below the summary). Pass `nil`/`""` to drop
# it — e.g. when the body is an absolutely-positioned popover whose
# wrapper would otherwise add ~8px of normal-flow margin and shove
# siblings down on open (see `goals/_color_picker`).
def initialize(title: nil, align: "right", open: false, variant: :default, summary_class: nil, body_class: "mt-2", **opts)
@title = title
@align = align.to_sym
@open = open
@variant = variant&.to_sym
@summary_class_override = summary_class
@body_class = body_class
@opts = opts
raise ArgumentError, "Invalid variant: #{@variant.inspect}. Must be one of #{VARIANTS.inspect}" unless VARIANTS.include?(@variant)

View File

@@ -1,6 +1,24 @@
class PagesController < ApplicationController
include Periodable
# Per-widget dashboard layout guardrails. Deterministic defaults the masonry
# packer reads; users may override a grow widget's height via presets.
# col_span: "single" | "full" (full spans both columns in 2-col mode)
# grow: true for charts that should fill an allotted height,
# false for content-sized widgets (tables, stat grids)
# min_height: floor in px
DASHBOARD_SECTION_LAYOUTS = {
"cashflow_sankey" => { col_span: "full", grow: false, min_height: 384, width_toggle: true },
"outflows_donut" => { col_span: "single", grow: false, min_height: 0 },
"investment_summary" => { col_span: "single", grow: false, min_height: 0, width_toggle: true },
"net_worth_chart" => { col_span: "single", grow: true, min_height: 208, width_toggle: true },
"balance_sheet" => { col_span: "single", grow: false, min_height: 0, width_toggle: true }
}.freeze
# Selectable height presets (px) for grow widgets.
DASHBOARD_HEIGHT_PRESETS = { "compact" => 208, "auto" => 288, "tall" => 416 }.freeze
DEFAULT_HEIGHT_PRESET = "auto"
skip_authentication only: %i[redis_configuration_error privacy terms]
before_action :ensure_intro_guest!, only: :intro
@@ -78,8 +96,9 @@ class PagesController < ApplicationController
def preferences_params
prefs = params.require(:preferences)
{}.tap do |permitted|
permitted["collapsed_sections"] = prefs[:collapsed_sections].to_unsafe_h if prefs[:collapsed_sections]
permitted["section_order"] = prefs[:section_order] if prefs[:section_order]
permitted["collapsed_sections"] = prefs[:collapsed_sections].to_unsafe_h if prefs[:collapsed_sections].respond_to?(:to_unsafe_h)
permitted["section_order"] = prefs[:section_order] if prefs[:section_order].is_a?(Array)
permitted["dashboard_section_layout"] = prefs[:dashboard_section_layout].to_unsafe_h if prefs[:dashboard_section_layout].respond_to?(:to_unsafe_h)
end
end
@@ -89,6 +108,7 @@ class PagesController < ApplicationController
key: "cashflow_sankey",
title: "pages.dashboard.cashflow_sankey.title",
partial: "pages/dashboard/cashflow_sankey",
layout: section_layout("cashflow_sankey"),
locals: { sankey_data: @cashflow_sankey_data, period: @period },
visible: @accounts.any?,
collapsible: true
@@ -97,6 +117,7 @@ class PagesController < ApplicationController
key: "outflows_donut",
title: "pages.dashboard.outflows_donut.title",
partial: "pages/dashboard/outflows_donut",
layout: section_layout("outflows_donut"),
locals: { outflows_data: @outflows_data, period: @period },
visible: @accounts.any? && @outflows_data[:categories].present?,
collapsible: true
@@ -105,6 +126,7 @@ class PagesController < ApplicationController
key: "investment_summary",
title: "pages.dashboard.investment_summary.title",
partial: "pages/dashboard/investment_summary",
layout: section_layout("investment_summary"),
locals: { investment_statement: @investment_statement, period: @period },
visible: @accounts.any? && @investment_statement.investment_accounts.any?,
collapsible: true
@@ -113,6 +135,7 @@ class PagesController < ApplicationController
key: "net_worth_chart",
title: "pages.dashboard.net_worth_chart.title",
partial: "pages/dashboard/net_worth_chart",
layout: section_layout("net_worth_chart"),
locals: { balance_sheet: @balance_sheet, period: @period },
visible: @accounts.any?,
collapsible: true
@@ -121,6 +144,7 @@ class PagesController < ApplicationController
key: "balance_sheet",
title: "pages.dashboard.balance_sheet.title",
partial: "pages/dashboard/balance_sheet",
layout: section_layout("balance_sheet"),
locals: { balance_sheet: @balance_sheet },
visible: @accounts.any?,
collapsible: true
@@ -141,6 +165,22 @@ class PagesController < ApplicationController
ordered_sections
end
# Resolves a section's layout guardrails, applying the user's height preset
# override (falling back to the deterministic default) for grow widgets.
def section_layout(key)
base = DASHBOARD_SECTION_LAYOUTS.fetch(key, { col_span: "single", grow: false, min_height: 0, width_toggle: false })
preset = Current.user.dashboard_section_height(key)
preset = DEFAULT_HEIGHT_PRESET unless DASHBOARD_HEIGHT_PRESETS.key?(preset)
col_span = base[:col_span]
if base[:width_toggle]
user_span = Current.user.dashboard_section_width(key)
col_span = user_span if %w[single full].include?(user_span)
end
base.merge(col_span: col_span, height_preset: preset, height_px: DASHBOARD_HEIGHT_PRESETS.fetch(preset))
end
def github_provider
Provider::Registry.get_provider(:github)
end

View File

@@ -8,5 +8,7 @@ class Settings::SecuritiesController < ApplicationController
]
@oidc_identities = Current.user.oidc_identities.order(:provider)
@webauthn_credentials = Current.user.webauthn_credentials.order(created_at: :asc)
@encryption_unconfigured = Rails.application.config.app_mode.self_hosted? &&
!ActiveRecordEncryptionConfig.explicitly_configured?
end
end

View File

@@ -0,0 +1,82 @@
import { Controller } from "@hotwired/stimulus";
// Packs variable-height dashboard cards into a tight masonry layout by computing
// each card's grid row span from its measured height, letting `grid-auto-flow:
// dense` fill the gaps. Active only when the grid is actually multi-column; in
// single-column mode it clears the spans and normal block flow takes over.
//
// The DOM stays a single flat list of <section data-section-key> children, so
// the dashboard-sortable controller (drag / keyboard reorder) is unaffected —
// reordering just moves a node and the CSS re-packs.
export default class extends Controller {
connect() {
this._scheduleLayout = this._scheduleLayout.bind(this);
this._frame = null;
this._resizeObserver = new ResizeObserver(this._scheduleLayout);
this._cards().forEach((card) => this._resizeObserver.observe(card));
this._scheduleLayout();
// Re-pack after the dashboard turbo frame re-renders (period change, reorder
// save) and on viewport changes that may cross the column breakpoint.
this.element.addEventListener("turbo:frame-load", this._scheduleLayout);
window.addEventListener("resize", this._scheduleLayout);
}
disconnect() {
this._resizeObserver?.disconnect();
this.element.removeEventListener("turbo:frame-load", this._scheduleLayout);
window.removeEventListener("resize", this._scheduleLayout);
if (this._frame) cancelAnimationFrame(this._frame);
this._cards().forEach((card) => {
card.style.gridRowEnd = "";
delete card.dataset.masonrySpan;
});
}
_cards() {
return Array.from(
this.element.querySelectorAll(":scope > [data-section-key]"),
);
}
_scheduleLayout() {
if (this._frame) cancelAnimationFrame(this._frame);
this._frame = requestAnimationFrame(() => this._layout());
}
_layout() {
const styles = getComputedStyle(this.element);
const columns = styles.gridTemplateColumns
.split(" ")
.filter(Boolean).length;
// Single column: let natural block flow handle it, clear any stale spans.
if (columns < 2) {
this._cards().forEach((card) => {
if (card.dataset.masonrySpan) {
card.style.gridRowEnd = "";
delete card.dataset.masonrySpan;
}
});
return;
}
// The row gap is zeroed in masonry mode (2xl:gap-y-0). We reproduce a uniform
// inter-card gap by padding each card's span with empty tracks equal to the
// column gap. Integer row-spans over a non-zero row gap otherwise overshoot
// by up to one gap's worth of slack, giving visibly uneven vertical gaps.
const rowUnit = Number.parseFloat(styles.gridAutoRows) || 1;
const gap = Number.parseFloat(styles.columnGap) || 0;
const gapTracks = Math.round(gap / rowUnit);
this._cards().forEach((card) => {
const height = card.getBoundingClientRect().height;
const span = Math.max(1, Math.ceil(height / rowUnit) + gapTracks);
if (card.dataset.masonrySpan !== String(span)) {
card.style.gridRowEnd = `span ${span}`;
card.dataset.masonrySpan = String(span);
}
});
}
}

View File

@@ -0,0 +1,110 @@
import { Controller } from "@hotwired/stimulus";
// Per-widget layout picker (mounted on the <details> menu). Optimistically applies
// the choice for instant feedback, then persists it fire-and-forget — mirroring how
// collapse / reorder already save dashboard preferences:
//
// - Height: sets the `--dash-widget-h` CSS var; the chart redraws via its own
// ResizeObserver and dashboard-masonry re-packs from the height change.
// - Width: toggles the `2xl:col-span-2` class; the CSS grid re-flows and a
// synthetic resize nudges dashboard-masonry to re-pack.
export default class extends Controller {
static values = { sectionKey: String };
connect() {
this._closeOnOutsideClick = this._closeOnOutsideClick.bind(this);
document.addEventListener("click", this._closeOnOutsideClick);
}
disconnect() {
document.removeEventListener("click", this._closeOnOutsideClick);
}
// Keep Enter/Space/arrow keydowns inside the menu from bubbling to the
// section-level dashboard-sortable handler, which would otherwise hijack them
// to toggle keyboard reorder mode.
stopKeydown(event) {
event.stopPropagation();
}
selectHeight(event) {
const { preset, height } = event.currentTarget.dataset;
const section = this._section();
if (section && height) {
section.style.setProperty("--dash-widget-h", `${height}px`);
}
this._markSelected(event.currentTarget);
this.element.open = false;
this._save({ height: preset });
}
selectWidth(event) {
const { colSpan } = event.currentTarget.dataset;
const section = this._section();
if (section) {
section.classList.toggle("2xl:col-span-2", colSpan === "full");
}
this._markSelected(event.currentTarget);
this.element.open = false;
this._save({ col_span: colSpan });
// Width change re-flows the grid; nudge dashboard-masonry to re-pack.
window.dispatchEvent(new Event("resize"));
}
_section() {
return this.element.closest("[data-section-key]");
}
// Move the active state to the clicked segment within its segmented control,
// mirroring DS::SegmentedControl's class + aria-pressed contract.
_markSelected(button) {
const group = button.closest(".segmented-control");
if (!group) return;
group.querySelectorAll(".segmented-control__segment").forEach((el) => {
const on = el === button;
el.classList.toggle("segmented-control__segment--active", on);
el.setAttribute("aria-pressed", on ? "true" : "false");
});
}
_closeOnOutsideClick(event) {
if (this.element.open && !this.element.contains(event.target)) {
this.element.open = false;
}
}
async _save(payload) {
const csrfToken = document.querySelector('meta[name="csrf-token"]');
if (!csrfToken) {
console.error("[Dashboard Widget Size] CSRF token not found.");
return;
}
try {
const response = await fetch("/dashboard/preferences", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken.content,
},
body: JSON.stringify({
preferences: {
dashboard_section_layout: { [this.sectionKeyValue]: payload },
},
}),
});
if (!response.ok) {
console.error(
"[Dashboard Widget Size] Failed to save layout:",
response.status,
);
}
} catch (error) {
console.error(
"[Dashboard Widget Size] Network error saving layout:",
error,
);
}
}
}

View File

@@ -1,4 +1,18 @@
class ApplicationJob < ActiveJob::Base
# Defer enqueuing until the surrounding Active Record transaction commits, so a
# job is never picked up by a worker before the records it references (by
# GlobalID) are committed and visible on the worker's connection — and is
# dropped entirely if the transaction rolls back. Without this, a job enqueued
# inside a transaction can be dequeued before COMMIT, fail to load its
# arguments (ActiveJob::DeserializationError), and be silently dropped by the
# `discard_on` below — the "stuck sync" regression for SyncJob enqueued in
# Syncable#sync_later.
#
# This is the Rails 8.2 default. On 8.1 the global
# `config.active_job.enqueue_after_transaction_commit` toggle is non-functional,
# so we set the class attribute on the base job, which every job inherits.
self.enqueue_after_transaction_commit = true
retry_on ActiveRecord::Deadlocked
discard_on ActiveJob::DeserializationError
queue_as :low_priority # default queue

View File

@@ -1,6 +1,10 @@
class DestroyJob < ApplicationJob
queue_as :low_priority
self.enqueue_after_transaction_commit = :never
# Inherits enqueue_after_transaction_commit = true from ApplicationJob. (This
# previously read `= :never`, the removed Rails 7.2 symbol API; under 8.1 that
# symbol is truthy, so it already deferred — the explicit line was dead and
# misleading.) Deferring is correct here: destroy after the enclosing
# transaction commits, never against an uncommitted/rolled-back record.
def perform(model)
model.destroy

View File

@@ -149,8 +149,17 @@ class CoinstatsItem::Importer
response = coinstats_provider.get_wallet_balances(wallets_param)
response.success? ? response.data : FETCH_FAILED
rescue => e
Rails.logger.warn "CoinstatsItem::Importer - Bulk balance fetch failed: #{e.message}"
FETCH_FAILED
log_debug_event(
level: "warn",
message: "CoinStats bulk balance fetch failed",
metadata: {
coinstats_item_id: coinstats_item.id,
wallets: wallets.map { |wallet| "#{wallet[:blockchain]}:#{wallet[:address]}" },
error_class: e.class.name,
error_message: e.message
}
)
nil
end
def fetch_portfolio_coins_for_exchange(linked_accounts)
@@ -244,15 +253,38 @@ class CoinstatsItem::Importer
return { success: false, error: "Missing address or blockchain" }
end
# Extract balance data for this specific wallet from the bulk response
balance_data = if bulk_balance_data.present?
coinstats_provider.extract_wallet_balance(bulk_balance_data, address, blockchain)
if bulk_balance_data.nil?
log_debug_event(
level: "warn",
message: "CoinStats wallet balance sync preserved existing snapshot after bulk fetch failure",
account_provider: coinstats_account.account_provider,
metadata: {
coinstats_item_id: coinstats_item.id,
coinstats_account_id: coinstats_account.id,
wallet_address: address,
blockchain: blockchain,
reason: "bulk_balance_data_missing"
}
)
elsif wallet_missing_from_bulk_balance_data?(bulk_balance_data, address, blockchain)
log_debug_event(
level: "warn",
message: "CoinStats wallet balance sync preserved existing snapshot because wallet was missing from bulk response",
account_provider: coinstats_account.account_provider,
metadata: {
coinstats_item_id: coinstats_item.id,
coinstats_account_id: coinstats_account.id,
wallet_address: address,
blockchain: blockchain,
reason: "wallet_missing_from_bulk_response"
}
)
else
[]
end
balance_data = coinstats_provider.extract_wallet_balance(bulk_balance_data, address, blockchain)
# Update the coinstats account with new balance data
coinstats_account.upsert_coinstats_snapshot!(normalize_balance_data(balance_data, coinstats_account))
# Update the coinstats account with new balance data
coinstats_account.upsert_coinstats_snapshot!(normalize_balance_data(balance_data, coinstats_account))
end
# Extract and merge transactions from bulk response
transactions_count = fetch_and_merge_transactions(coinstats_account, address, blockchain, bulk_transactions_data)
@@ -260,6 +292,30 @@ class CoinstatsItem::Importer
{ success: true, transactions_count: transactions_count }
end
def wallet_missing_from_bulk_balance_data?(bulk_balance_data, address, blockchain)
return true unless bulk_balance_data.is_a?(Array)
bulk_balance_data.none? do |entry|
entry = entry.with_indifferent_access
entry[:address]&.downcase == address&.downcase &&
(entry[:connectionId]&.downcase == blockchain&.downcase ||
entry[:blockchain]&.downcase == blockchain&.downcase)
end
end
def log_debug_event(level:, message:, metadata:, account_provider: nil)
DebugLogEntry.capture(
category: "provider_sync_error",
level: level,
message: message,
source: self.class.name,
provider_key: "coinstats",
family: coinstats_item.family,
account_provider: account_provider,
metadata: metadata
)
end
def update_exchange_account(coinstats_account, portfolio_coins_data:, portfolio_transactions_data:)
portfolio_id = exchange_portfolio_id_for(coinstats_account)
balance_data = portfolio_coins_data[portfolio_id]

View File

@@ -41,20 +41,20 @@ module Enrichable
end
# Convenience method for a single attribute
def enrich_attribute(attr, value, source:, metadata: {})
enrich_attributes({ attr => value }, source:, metadata:)
def enrich_attribute(attr, value, source:, metadata: {}, ignore_locks: false)
enrich_attributes({ attr => value }, source:, metadata:, ignore_locks:)
end
# Enriches and logs all attributes that:
# - Are not locked
# - Are not locked (unless ignore_locks: true, e.g. an explicit rule re-apply)
# - Are not ignored
# - Have changed value from the last saved value
# Returns true if any attributes were actually changed, false otherwise
def enrich_attributes(attrs, source:, metadata: {})
def enrich_attributes(attrs, source:, metadata: {}, ignore_locks: false)
# Track current values before modification for virtual attributes (like tag_ids)
current_values = {}
enrichable_attrs = Array(attrs).reject do |attr_key, attr_value|
if locked?(attr_key) || ignored_enrichable_attributes.include?(attr_key)
if (locked?(attr_key) && !ignore_locks) || ignored_enrichable_attributes.include?(attr_key)
true
else
# For virtual attributes (like tag_ids), use the getter method

View File

@@ -1,29 +1,6 @@
class Family::Syncer
attr_reader :family
# Registry of item association names that participate in family sync.
# Each model must:
# 1. Include Syncable
# 2. Define a `syncable` scope (items ready for auto-sync)
#
# To add a new provider: add its association name here.
# The model handles its own "ready to sync" logic via the syncable scope.
SYNCABLE_ITEM_ASSOCIATIONS = %i[
plaid_items
simplefin_items
lunchflow_items
akahu_items
enable_banking_items
indexa_capital_items
coinbase_items
coinstats_items
mercury_items
brex_items
binance_items
snaptrade_items
sophtron_items
].freeze
def initialize(family)
@family = family
end
@@ -49,14 +26,25 @@ class Family::Syncer
private
# Collects all syncable items from registered providers + manual accounts.
# Each provider model defines its own `syncable` scope that encapsulates
# the "ready to sync" business logic (active, configured, etc.)
# Collect all syncable provider items via reflection so new `*_items`
# integrations participate in nightly family sync as soon as they include
# Syncable and expose a `syncable` scope.
def child_syncables
provider_items = SYNCABLE_ITEM_ASSOCIATIONS.flat_map do |association|
provider_items = syncable_item_associations.flat_map do |association|
family.public_send(association).syncable
end
provider_items + family.accounts.manual
end
def syncable_item_associations
Family.reflect_on_all_associations(:has_many).filter_map do |association|
next unless association.name.to_s.end_with?("_items")
next unless association.klass.included_modules.include?(Syncable)
association.name
rescue NameError
nil
end
end
end

View File

@@ -19,7 +19,8 @@ class Rule::ActionExecutor::ExcludeTransaction < Rule::ActionExecutor
txn.entry.enrich_attribute(
:excluded,
true,
source: "rule"
source: "rule",
ignore_locks: ignore_attribute_locks
)
end
end

View File

@@ -24,7 +24,8 @@ class Rule::ActionExecutor::SetInvestmentActivityLabel < Rule::ActionExecutor
txn.enrich_attribute(
:investment_activity_label,
value,
source: "rule"
source: "rule",
ignore_locks: ignore_attribute_locks
)
end
end

View File

@@ -22,7 +22,8 @@ class Rule::ActionExecutor::SetTransactionCategory < Rule::ActionExecutor
txn.enrich_attribute(
:category_id,
category.id,
source: "rule"
source: "rule",
ignore_locks: ignore_attribute_locks
)
end
end

View File

@@ -21,7 +21,8 @@ class Rule::ActionExecutor::SetTransactionMerchant < Rule::ActionExecutor
txn.enrich_attribute(
:merchant_id,
merchant.id,
source: "rule"
source: "rule",
ignore_locks: ignore_attribute_locks
)
end
end

View File

@@ -24,7 +24,8 @@ class Rule::ActionExecutor::SetTransactionName < Rule::ActionExecutor
txn.entry.enrich_attribute(
:name,
value,
source: "rule"
source: "rule",
ignore_locks: ignore_attribute_locks
)
end
end

View File

@@ -26,7 +26,8 @@ class Rule::ActionExecutor::SetTransactionTags < Rule::ActionExecutor
txn.enrich_attribute(
:tag_ids,
merged_tag_ids,
source: "rule"
source: "rule",
ignore_locks: ignore_attribute_locks
)
end
end

View File

@@ -314,6 +314,16 @@ class User < ApplicationRecord
preferences&.[]("section_order") || default_dashboard_section_order
end
# Per-widget height preset override ("compact" | "auto" | "tall"); nil = use default.
def dashboard_section_height(section_key)
preferences&.dig("dashboard_section_layout", section_key, "height")
end
# Per-widget column-span override ("single" | "full"); nil = use default.
def dashboard_section_width(section_key)
preferences&.dig("dashboard_section_layout", section_key, "col_span")
end
def update_dashboard_preferences(prefs)
# Use pessimistic locking to ensure atomic read-modify-write
# This prevents race conditions when multiple sections are collapsed quickly
@@ -324,7 +334,9 @@ class User < ApplicationRecord
prefs.each do |key, value|
if value.is_a?(Hash)
updated_prefs[key] ||= {}
updated_prefs[key] = updated_prefs[key].merge(value)
# deep_merge so a partial update of one nested dimension (e.g. a widget's
# col_span) doesn't clobber a sibling dimension (e.g. its height).
updated_prefs[key] = updated_prefs[key].deep_merge(value)
else
updated_prefs[key] = value
end

View File

@@ -14,16 +14,20 @@
<% end %>
</span>
<%# Raw <details> instead of DS::Disclosure: the disclosure wraps its body
in an `mt-2` div that lives in normal flow, which pushed the form down
~8px every time this absolutely-positioned popover opened. %>
<details class="group"
data-color-icon-picker-target="details"
data-action="mousedown->color-icon-picker#handleOutsideClick">
<summary aria-label="<%= t("goals.color_picker.trigger_label") %>"
class="list-none cursor-pointer absolute -bottom-1 -right-1 flex justify-center items-center bg-surface-inset hover:bg-surface-inset-hover border-2 w-6 h-6 border-subdued rounded-full text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-300">
<%# `body_class: nil` drops DS::Disclosure's default `mt-2` body wrapper:
the body here is an absolutely-positioned popover, so that wrapper's
normal-flow margin would otherwise push the form down ~8px on open. %>
<%= render DS::Disclosure.new(
body_class: nil,
summary_class: "list-none cursor-pointer absolute -bottom-1 -right-1 flex justify-center items-center bg-surface-inset hover:bg-surface-inset-hover border-2 w-6 h-6 border-subdued rounded-full text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-300",
data: {
color_icon_picker_target: "details",
action: "mousedown->color-icon-picker#handleOutsideClick"
}) do |disclosure| %>
<% disclosure.with_summary_content do %>
<span class="sr-only"><%= t("goals.color_picker.trigger_label") %></span>
<%= icon("pen", size: "xs") %>
</summary>
<% end %>
<div class="absolute top-full left-1/2 -translate-x-1/2 mt-2 z-50 bg-container p-3 border border-alpha-black-25 rounded-2xl shadow-xs w-80 max-w-[calc(100vw-2rem)] max-h-[60vh] overflow-y-auto"
data-color-icon-picker-target="popup">
@@ -69,6 +73,6 @@
</div>
</div>
</div>
</details>
<% end %>
</div>
</div>

View File

@@ -36,12 +36,14 @@
</div>
<% end %>
<div class="grid grid-cols-1 <%= "xl:grid-cols-2" if Current.user.dashboard_two_column? %> gap-6 pb-6 lg:pb-12" data-controller="dashboard-sortable" data-action="dragover->dashboard-sortable#dragOver drop->dashboard-sortable#drop" role="list" aria-label="Dashboard sections">
<% two_column = Current.user.dashboard_two_column? %>
<div class="grid grid-cols-1 <%= "2xl:grid-cols-2 2xl:items-start 2xl:gap-y-0 2xl:[grid-auto-flow:row_dense] 2xl:[grid-auto-rows:1px]" if two_column %> gap-6 pb-6 lg:pb-12" data-controller="dashboard-sortable dashboard-masonry" data-action="dragover->dashboard-sortable#dragOver drop->dashboard-sortable#drop" role="list" aria-label="<%= t("pages.dashboard.sections_aria_label") %>">
<% if accessible_accounts.any? %>
<% @dashboard_sections.each do |section| %>
<% next unless section[:visible] %>
<section
class="bg-container rounded-xl shadow-border-xs transition-all group focus:outline-none focus-visible:ring-2 focus-visible:ring-gray-900 focus-visible:ring-offset-2"
class="bg-container rounded-xl shadow-border-xs transition-all group focus:outline-none focus-visible:ring-2 focus-visible:ring-gray-900 focus-visible:ring-offset-2<%= " 2xl:col-span-2" if two_column && section[:layout][:col_span] == "full" %>"
style="<%= "--dash-widget-h: #{section[:layout][:height_px]}px;" if section[:layout][:grow] %>"
data-dashboard-sortable-target="section"
data-section-key="<%= section[:key] %>"
data-controller="dashboard-section<%= " cashflow-expand" if section[:key] == "cashflow_sankey" %>"
@@ -85,6 +87,57 @@
<%= icon("maximize-2", size: "sm", class: "!w-3.5 !h-3.5") %>
</button>
<% end %>
<% layout = section[:layout] %>
<% show_width = layout[:width_toggle] && two_column %>
<% if layout[:grow] || show_width %>
<details
class="relative hidden lg:block"
data-controller="dashboard-widget-size"
data-dashboard-widget-size-section-key-value="<%= section[:key] %>"
data-action="keydown->dashboard-widget-size#stopKeydown">
<summary
class="list-none cursor-pointer text-secondary hover:text-primary transition-colors p-0.5 opacity-0 group-hover:opacity-100 [&::-webkit-details-marker]:hidden"
aria-label="<%= t("pages.dashboard.widget_size.label") %>">
<%= icon("sliders-horizontal", size: "sm", class: "!w-3.5 !h-3.5") %>
</summary>
<div class="absolute right-0 top-full mt-1.5 z-10 w-48 p-2.5 space-y-2.5 bg-surface rounded-xl shadow-border-xs border border-primary">
<% if show_width %>
<div class="space-y-1.5">
<p class="flex items-center gap-1.5 text-xs font-medium text-secondary">
<%= icon("move-horizontal", size: "xs") %>
<%= t("pages.dashboard.widget_size.width_label") %>
</p>
<%= render DS::SegmentedControl.new(full_width: true, aria_label: t("pages.dashboard.widget_size.width_label")) do |sc| %>
<% { "single" => "half", "full" => "full" }.each do |value, i18n_key| %>
<% sc.with_segment(
t("pages.dashboard.widget_size.#{i18n_key}"),
active: layout[:col_span] == value,
data: { action: "dashboard-widget-size#selectWidth", col_span: value }
) %>
<% end %>
<% end %>
</div>
<% end %>
<% if layout[:grow] %>
<div class="space-y-1.5">
<p class="flex items-center gap-1.5 text-xs font-medium text-secondary">
<%= icon("move-vertical", size: "xs") %>
<%= t("pages.dashboard.widget_size.height_label") %>
</p>
<%= render DS::SegmentedControl.new(full_width: true, aria_label: t("pages.dashboard.widget_size.height_label")) do |sc| %>
<% PagesController::DASHBOARD_HEIGHT_PRESETS.each do |preset, px| %>
<% sc.with_segment(
t("pages.dashboard.widget_size.#{preset}"),
active: layout[:height_preset] == preset,
data: { action: "dashboard-widget-size#selectHeight", preset: preset, height: px }
) %>
<% end %>
<% end %>
</div>
<% end %>
</div>
</details>
<% end %>
<button
type="button"
class="cursor-grab active:cursor-grabbing text-secondary hover:text-primary transition-colors p-0.5 opacity-0 group-hover:opacity-100 hidden lg:block"

View File

@@ -18,7 +18,8 @@
<% if series.any? %>
<div
id="netWorthChart"
class="w-full flex-1 min-h-52 privacy-sensitive"
class="w-full min-h-52 privacy-sensitive"
style="height: var(--dash-widget-h, 13rem);"
data-controller="time-series-chart"
data-time-series-chart-data-value="<%= series.to_json %>"></div>
<% else %>

View File

@@ -133,9 +133,9 @@
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<tbody class="divide-y divide-alpha-black-200 theme-dark:divide-alpha-white-200">
<% @recent_runs.each do |run| %>
<tr class="<%= "bg-red-50 theme-dark:bg-red-950/30" if run.failed? %>">
<tr class="<%= "bg-red-tint-5 theme-dark:bg-red-tint-10" if run.failed? %>">
<td class="px-4 py-3 text-sm text-primary whitespace-nowrap">
<%= run.executed_at.strftime("%b %d, %Y %I:%M %p") %>
</td>
@@ -155,7 +155,7 @@
<% end %>
<% if run.failed? && run.error_message.present? %>
<div data-controller="tooltip" data-tooltip-content-value="<%= run.error_message %>">
<%= icon("info", size: "sm", class: "text-red-500") %>
<%= icon("info", size: "sm", color: "destructive") %>
</div>
<% end %>
</div>

View File

@@ -115,9 +115,9 @@
<th class="px-4 py-3 text-right text-xs font-medium text-secondary uppercase"><%= t(".col_cost") %></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<tbody class="divide-y divide-alpha-black-200 theme-dark:divide-alpha-white-200">
<% @llm_usages.each do |usage| %>
<tr class="<%= "bg-red-50 theme-dark:bg-red-950/30" if usage.failed? %>">
<tr class="<%= "bg-red-tint-5 theme-dark:bg-red-tint-10" if usage.failed? %>">
<td class="px-4 py-3 text-sm text-primary whitespace-nowrap">
<%= usage.created_at.strftime("%b %d, %Y %I:%M %p") %>
</td>
@@ -131,8 +131,8 @@
<% if usage.failed? %>
<div data-controller="tooltip" class="inline-flex justify-end">
<div class="inline-flex items-center gap-1 cursor-help">
<%= icon "alert-circle", class: "w-4 h-4 text-red-600 theme-dark:text-red-400" %>
<span class="text-red-600 theme-dark:text-red-400 font-medium"><%= t(".failed") %></span>
<%= icon "alert-circle", size: "sm", color: "destructive" %>
<span class="text-destructive font-medium"><%= t(".failed") %></span>
</div>
<div role="tooltip" data-tooltip-target="tooltip" class="tooltip bg-inverse text-sm p-2 rounded-md w-64 text-left break-words whitespace-normal hidden">
<div class="text-inverse">

View File

@@ -1,5 +1,19 @@
<%= content_for :page_title, t(".page_title") %>
<% if @encryption_unconfigured %>
<div class="mb-4">
<%= render DS::Alert.new(variant: :warning, title: t(".encryption_warning.title")) do %>
<p><%= t(".encryption_warning.intro") %></p>
<ul class="list-disc pl-5">
<% t(".encryption_warning.keys").each do |key| %>
<li><%= key %></li>
<% end %>
</ul>
<p><%= t(".encryption_warning.generate") %></p>
<% end %>
</div>
<% end %>
<%= settings_section title: t(".mfa_title"), subtitle: t(".mfa_description") do %>
<div class="space-y-4">
<div class="p-3 shadow-border-xs bg-container rounded-lg md:flex md:justify-between md:items-center">

View File

@@ -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

View File

@@ -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: 关闭

View File

@@ -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: 名称 ↓

View File

@@ -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: 目标关联账户后不能更改货币。

View File

@@ -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: 此账户已有相同金额的未完成承诺。记录新的承诺前,请先取消或延长现有承诺。

View File

@@ -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}

View File

@@ -0,0 +1,8 @@
---
zh-CN:
activerecord:
errors:
models:
merchant_import:
missing_columns: 缺少必填列:%{columns}
duplicate_columns: 规范化后列名重复:%{columns}

View File

@@ -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}

View File

@@ -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 账户并选择要关联的账户。

View File

@@ -20,6 +20,7 @@ zh-CN:
import_selected: 导入所选
cancel: 取消
creating: 正在导入...
historical_import: 历史导入
complete_account_setup:
success:
one: 已导入 %{count} 个账户

View File

@@ -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 连接已更新

View File

@@ -50,6 +50,7 @@ zh-CN:
all: 全部
on_track: 正常
over_budget: 超支
aria_label: 筛选预算分类
tabs:
actual: 实际
budgeted: 预算

View File

@@ -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:

View File

@@ -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: 查看数据质量详情

View File

@@ -23,3 +23,4 @@ zh-CN:
expiration_date: 到期日期
minimum_payment: 最低还款额
unknown: 未知
edit_account_details: 编辑账户详情

View File

@@ -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: 加密交易所

View File

@@ -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: 储蓄账户

View File

@@ -74,6 +74,7 @@ zh-CN:
create_accounts: 创建账户
creating_accounts: 正在创建账户...
cancel: 取消
psd2_savings_notice: 根据 PSD2 规则,部分储蓄账户可能需要额外授权。
new:
link_enable_banking_title: 连接 Enable Banking
session_expired: 会话已过期 - 需要重新授权

View File

@@ -12,3 +12,12 @@ zh-CN:
loading: 正在加载记录...
update:
success: 记录已更新
unlock:
success: 记录解除锁定成功
protection:
tooltip: 受同步保护
title: 受同步保护
description: 你对此记录的编辑不会被提供商同步覆盖。
locked_fields_label: 锁定字段:
unlock_button: 允许同步更新
unlock_confirm: 允许同步更新此记录?下次同步时你的更改可能会被覆盖。

View File

@@ -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: 导出数据

View File

@@ -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: 只有待处理的承诺可以取消。

View File

@@ -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: 按当前进度,你大约会在 <strong class="text-primary whitespace-nowrap">%{date}</strong> 达成此目标。
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: 其他

View File

@@ -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: 请求代入访问

View File

@@ -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:

View File

@@ -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: 从 QuickenQIF导入
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: 未知

View File

@@ -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}

View File

@@ -11,6 +11,7 @@ zh-CN:
existing_user_added: 用户已添加到您的家庭中。
failure: 无法发送邀请
success: 邀请已成功发送
existing_user_has_family_data: 此用户已有家庭数据,无法接受该邀请。
destroy:
failure: 移除邀请时出现问题。
not_authorized: 您无权管理邀请。

View File

@@ -10,6 +10,7 @@ zh-CN:
home: 主页
reports: 报表
transactions: 交易
goals: 目标
auth:
existing_account: 已经有账户?
no_account: 新加入 %{product_name}

View File

@@ -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: 编辑贷款详情

View File

@@ -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 账户都已设置完成。

View File

@@ -34,6 +34,7 @@ zh-CN:
merchant: 商户
actions: 操作
source: 来源
import: 导入商户
family_merchant:
edit: 编辑
delete: 删除

View File

@@ -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: 取消
cancel: 取消
no_pending_oidc: 未找到待处理的 OIDC 认证。
create_link:
no_pending_oidc: 未找到待处理的 OIDC 认证。
create_user:
no_pending_oidc: 未找到待处理的 OIDC 认证。
account_creation_disabled: SSO 账户创建已禁用。请联系管理员。
account_created: 欢迎!你的账户已创建。

View File

@@ -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

View File

@@ -90,3 +90,6 @@ zh-CN:
trades: 交易
no_investments: 暂无投资账户
add_investment: 添加投资账户以跟踪您的投资组合
release_notes_unavailable:
name: 发布说明暂不可用
body_html: "<p>暂时无法获取最新发布说明。请稍后再试,或直接访问 <a href='https://github.com/we-promise/sure/releases' target='_blank' rel='noopener noreferrer'>GitHub releases 页面</a>。</p>"

View File

@@ -11,3 +11,5 @@ zh-CN:
update:
invalid_token: 验证令牌无效。
success: 密码已重置成功。
disabled: Sure 密码重置已禁用。请通过身份提供商重置密码。
sso_only_user: 你的账户使用 SSO 登录。请联系管理员管理凭据。

View File

@@ -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。

View File

@@ -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: 估算市值

View File

@@ -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: 收入

View File

@@ -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 密钥失败

View File

@@ -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

View File

@@ -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!:

View File

@@ -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: 前往 <a href="https://twelvedata.com/account/api-keys" target="_blank" rel="noopener noreferrer" class="underline">API Keys</a> 页面。
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: 前往 <a href="https://www.tiingo.com/account/api/token" target="_blank" rel="noopener noreferrer" class="underline">API Token</a> 页面。
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: 已通过环境变量配置同步设置。

View File

@@ -8,3 +8,24 @@ zh-CN:
enable_mfa: 启用双重认证
mfa_description: 在登录时要求输入身份验证器应用中的代码,为账户增加额外安全层
mfa_title: 双重认证
mfa_enabled_status_html: 双重认证已<span class="font-medium text-green-600">启用</span>
mfa_enabled_description: 你的账户受到额外安全层保护。
mfa_disabled_status_html: 双重认证已<span class="font-medium text-red-600">禁用</span>
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: 通行密钥或安全密钥已移除。

View File

@@ -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: '前往 <a href="https://pro.kraken.com/app/settings/api" target="_blank" rel="noopener noreferrer" class="underline">Kraken API 设置</a>'
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、保存然后关联同步到账户。

View File

@@ -36,3 +36,7 @@ zh-CN:
expense: 支出
income: 收入
transfer: 转账
preview: 预览
sync_toast:
message: 有新数据可用
refresh: 刷新

View File

@@ -155,5 +155,5 @@ zh-CN:
other: 已勾稽 %{count} 笔重复待处理交易
stale_pending_status:
message:
one: 1 笔待处理交易早于 %{days} 天
one: "%{count} 笔待处理交易早于 %{days} 天"
other: "%{count} 笔待处理交易早于 %{days} 天"

View File

@@ -11,4 +11,14 @@ zh-CN:
today: "今天"
redirect_to_stripe: "在下一步中,您将被重定向到 Stripe它为我们处理信用卡。"
trialing: "您的数据将在 %{days} 天后删除"
trial_over: "您的试用期已结束"
trial_over: "您的试用期已结束"
already_contributing: 你已经在贡献。谢谢!
page_title: 升级
account_settings: 账户设置
sign_out: 退出登录
create:
welcome: 欢迎使用 Sure
trial_already_used: 你已经开始或完成过试用。请升级以继续使用。
success:
welcome_with_contribution: 欢迎使用 Sure感谢你的贡献。
contribution_failed: 处理贡献时出现问题。请重试。

View File

@@ -11,3 +11,4 @@ zh-CN:
explanation: "“%{tag_name}”将从交易及其他可标记实体中移除。您也可以在下述选项中选择一个新标签进行重新分配,而不是保留为未标记状态。"
replacement_tag_prompt: 选择新标签
tag: 标签
delete_and_reassign: 删除并重新分配

View File

@@ -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: 所有标签已删除

View File

@@ -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: 拆分交易的一部分

View File

@@ -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: 转账已更新

View File

@@ -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: 超级管理员

View File

@@ -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: 此日期的账户价值

View File

@@ -32,3 +32,4 @@ zh-CN:
trend: 走势
unknown: 未知
year: 年份
edit_account_details: 编辑账户详情

View File

@@ -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<LoginScreen> {
// Login Button
Consumer<AuthProvider>(
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<LoginScreen> {
// Google Sign-In button
Consumer<AuthProvider>(
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<LoginScreen> {
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<AuthProvider>(
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),
);
},
),
],
),

View File

@@ -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<SureButton> createState() => _SureButtonState();
}
class _SureButtonState extends State<SureButton> {
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<Color>(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: <Type, Action<Intent>>{
ActivateIntent: CallbackAction<ActivateIntent>(
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,
);
}
}
}

View File

@@ -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<void> pump(WidgetTester tester, Widget child) {
return tester.pumpWidget(
MaterialApp(
theme: SureTheme.light,
home: Scaffold(body: Center(child: child)),
),
);
}
BoxDecoration decoration(WidgetTester tester) =>
tester.widget<AnimatedContainer>(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<Text>(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<Text>(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<Opacity>(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<AnimatedContainer>(find.byType(AnimatedContainer))
.constraints
?.minHeight,
48,
);
});
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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!

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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: {})