mirror of
https://github.com/we-promise/sure.git
synced 2026-07-13 21:35:20 +00:00
Merge branch 'we-promise:main' into Transfer-charges
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.4.7
|
||||
3.4.9
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -963,7 +963,7 @@ DEPENDENCIES
|
||||
webmock
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.4.7p58
|
||||
ruby 3.4.9p82
|
||||
|
||||
BUNDLED WITH
|
||||
2.6.7
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<div class="mt-2">
|
||||
<%= tag.div class: body_class.presence do %>
|
||||
<%= content %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
82
app/javascript/controllers/dashboard_masonry_controller.js
Normal file
82
app/javascript/controllers/dashboard_masonry_controller.js
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
110
app/javascript/controllers/dashboard_widget_size_controller.js
Normal file
110
app/javascript/controllers/dashboard_widget_size_controller.js
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
21
config/initializers/encryption_warning.rb
Normal file
21
config/initializers/encryption_warning.rb
Normal 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
|
||||
@@ -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: 关闭
|
||||
|
||||
@@ -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: 名称 ↓
|
||||
|
||||
25
config/locales/models/goal/zh-CN.yml
Normal file
25
config/locales/models/goal/zh-CN.yml
Normal 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: 目标关联账户后不能更改货币。
|
||||
20
config/locales/models/goal_pledge/zh-CN.yml
Normal file
20
config/locales/models/goal_pledge/zh-CN.yml
Normal 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: 此账户已有相同金额的未完成承诺。记录新的承诺前,请先取消或延长现有承诺。
|
||||
@@ -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}
|
||||
|
||||
8
config/locales/models/merchant_import/zh-CN.yml
Normal file
8
config/locales/models/merchant_import/zh-CN.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
zh-CN:
|
||||
activerecord:
|
||||
errors:
|
||||
models:
|
||||
merchant_import:
|
||||
missing_columns: 缺少必填列:%{columns}
|
||||
duplicate_columns: 规范化后列名重复:%{columns}
|
||||
@@ -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}
|
||||
|
||||
127
config/locales/views/akahu_items/zh-CN.yml
Normal file
127
config/locales/views/akahu_items/zh-CN.yml
Normal 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 账户并选择要关联的账户。
|
||||
@@ -20,6 +20,7 @@ zh-CN:
|
||||
import_selected: 导入所选
|
||||
cancel: 取消
|
||||
creating: 正在导入...
|
||||
historical_import: 历史导入
|
||||
complete_account_setup:
|
||||
success:
|
||||
one: 已导入 %{count} 个账户
|
||||
|
||||
@@ -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 连接已更新
|
||||
|
||||
@@ -50,6 +50,7 @@ zh-CN:
|
||||
all: 全部
|
||||
on_track: 正常
|
||||
over_budget: 超支
|
||||
aria_label: 筛选预算分类
|
||||
tabs:
|
||||
actual: 实际
|
||||
budgeted: 预算
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: 查看数据质量详情
|
||||
|
||||
@@ -23,3 +23,4 @@ zh-CN:
|
||||
expiration_date: 到期日期
|
||||
minimum_payment: 最低还款额
|
||||
unknown: 未知
|
||||
edit_account_details: 编辑账户详情
|
||||
|
||||
@@ -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: 加密交易所
|
||||
|
||||
@@ -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: 储蓄账户
|
||||
|
||||
@@ -74,6 +74,7 @@ zh-CN:
|
||||
create_accounts: 创建账户
|
||||
creating_accounts: 正在创建账户...
|
||||
cancel: 取消
|
||||
psd2_savings_notice: 根据 PSD2 规则,部分储蓄账户可能需要额外授权。
|
||||
new:
|
||||
link_enable_banking_title: 连接 Enable Banking
|
||||
session_expired: 会话已过期 - 需要重新授权
|
||||
|
||||
@@ -12,3 +12,12 @@ zh-CN:
|
||||
loading: 正在加载记录...
|
||||
update:
|
||||
success: 记录已更新
|
||||
unlock:
|
||||
success: 记录解除锁定成功
|
||||
protection:
|
||||
tooltip: 受同步保护
|
||||
title: 受同步保护
|
||||
description: 你对此记录的编辑不会被提供商同步覆盖。
|
||||
locked_fields_label: 锁定字段:
|
||||
unlock_button: 允许同步更新
|
||||
unlock_confirm: 允许同步更新此记录?下次同步时你的更改可能会被覆盖。
|
||||
|
||||
@@ -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: 导出数据
|
||||
|
||||
20
config/locales/views/goal_pledges/zh-CN.yml
Normal file
20
config/locales/views/goal_pledges/zh-CN.yml
Normal 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: 只有待处理的承诺可以取消。
|
||||
268
config/locales/views/goals/zh-CN.yml
Normal file
268
config/locales/views/goals/zh-CN.yml
Normal 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: 其他
|
||||
@@ -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: 请求代入访问
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -130,6 +130,10 @@ zh-CN:
|
||||
show:
|
||||
description: 选择与 CSV 中每个字段对应的列。
|
||||
title: 配置您的导入
|
||||
merchant_import:
|
||||
button_label: 继续
|
||||
description: 导入商户列表,用于补全交易商户信息。
|
||||
instructions: 上传包含商户名称、网站和颜色等字段的 CSV 文件。
|
||||
confirms:
|
||||
sure_import:
|
||||
title: 确认您的导入
|
||||
@@ -157,6 +161,8 @@ zh-CN:
|
||||
tag_mapping_description: 将导入文件中的所有标签分配到 %{product_name} 中现有的标签。您也可以添加新标签,或将其保留为未分类。
|
||||
tag_mapping_title: 分配您的标签
|
||||
uploads:
|
||||
handle_qif_upload:
|
||||
qif_uploaded: QIF 文件上传成功。
|
||||
update:
|
||||
qif_uploaded: QIF 文件上传成功。
|
||||
show:
|
||||
@@ -236,6 +242,8 @@ zh-CN:
|
||||
category_parent: 父分类
|
||||
category_color: 颜色
|
||||
category_icon: Lucide 图标
|
||||
merchant_color: 颜色
|
||||
merchant_website: 网站 URL
|
||||
update:
|
||||
account_saved: 账户已保存。
|
||||
invalid_account: 未找到账户。
|
||||
@@ -267,6 +275,19 @@ zh-CN:
|
||||
confirm_revert: 这将删除已导入的交易记录,但您仍然可以随时查看和重新导入您的数据。
|
||||
delete: 删除
|
||||
view: 查看
|
||||
type_labels:
|
||||
transaction_import: 交易
|
||||
trade_import: 投资交易
|
||||
account_import: 账户
|
||||
mint_import: Mint
|
||||
actual_import: Actual
|
||||
qif_import: QIF
|
||||
category_import: 分类
|
||||
rule_import: 规则
|
||||
merchant_import: 商户
|
||||
pdf_import: PDF
|
||||
document_import: 文档
|
||||
sure_import: Sure
|
||||
empty: 暂无导入记录。
|
||||
new:
|
||||
description: 您可以通过 CSV 手动导入多种类型数据,或使用我们的导入模板(如 Mint 格式)。
|
||||
@@ -279,6 +300,18 @@ zh-CN:
|
||||
resume: 继续 %{type}
|
||||
sources: 数据来源
|
||||
title: 新建 CSV 导入
|
||||
tab_financial_tools: 财务工具和文件
|
||||
tab_raw_data: 原始数据
|
||||
coming_soon: 即将推出
|
||||
import_ynab: 从 YNAB 导入
|
||||
import_merchants: 导入商户
|
||||
import_actual: 从 Actual Budget 导入
|
||||
import_qif: 从 Quicken(QIF)导入
|
||||
import_sure: 从 Sure 导入
|
||||
import_sure_description: 导入 Sure 导出的数据文件。
|
||||
import_file: 导入文档
|
||||
import_file_description: 上传账单、CSV 或其他财务文档。
|
||||
requires_account: 导入前需要先创建账户。
|
||||
ready:
|
||||
description: 以下是发布导入后将添加到您账户的新项目摘要。
|
||||
title: 确认导入数据
|
||||
@@ -287,3 +320,130 @@ zh-CN:
|
||||
empty_summary: 在此文件中未找到可导入的记录。文件可能为空,或各行不符合预期的导出格式(每行应为包含「type」和「data」键的 JSON 对象,且类型须为本导入支持的类型)。
|
||||
publish_import: 发布导入
|
||||
back_to_imports: 返回导入列表
|
||||
apply_template:
|
||||
template_applied: 模板已应用。
|
||||
no_template_found: 找不到可用模板。
|
||||
destroy:
|
||||
deleted: 导入已删除。
|
||||
failure:
|
||||
title: 导入 失败
|
||||
description: 导入过程中出现问题。
|
||||
try_again: 重试
|
||||
success:
|
||||
title: 导入成功
|
||||
description: 导入成功说明
|
||||
back_to_dashboard: 返回仪表盘
|
||||
verification:
|
||||
title: 回读校验
|
||||
checked: 已检查
|
||||
mismatches: 不匹配
|
||||
status:
|
||||
not_verified: 未校验
|
||||
matched: 已匹配
|
||||
mismatch: 不匹配
|
||||
failed: 失败
|
||||
reverted: 已回滚
|
||||
importing:
|
||||
title: 正在导入
|
||||
description: 正在处理导入文件,请稍候。
|
||||
check_status: 检查状态
|
||||
back_to_dashboard: 返回仪表盘
|
||||
revert_failure:
|
||||
title: 回滚导入失败
|
||||
description: 请重试
|
||||
try_again: 重试
|
||||
date_format:
|
||||
heading: 日期格式
|
||||
description: 选择导入文件中日期字段使用的格式。
|
||||
preview: 第一条解析日期
|
||||
error_title: 日期格式无效
|
||||
error_description: 无法按所选格式解析日期,请检查示例。
|
||||
type_labels:
|
||||
transaction_import: 交易导入
|
||||
trade_import: 投资交易导入
|
||||
account_import: 账户导入
|
||||
mint_import: Mint 导入
|
||||
actual_import: Actual 导入
|
||||
qif_import: QIF 导入
|
||||
category_import: 分类导入
|
||||
rule_import: 规则导入
|
||||
merchant_import: 商户导入
|
||||
pdf_import: PDF 导入
|
||||
document_import: 文档导入
|
||||
sure_import: Sure 导入
|
||||
steps:
|
||||
upload: 上传
|
||||
configure: 配置
|
||||
clean: 清理
|
||||
map: 映射
|
||||
confirm: 确认
|
||||
select: 选择
|
||||
progress: 第 %{step} 步,共 %{total} 步
|
||||
empty:
|
||||
message: 未找到导入记录。
|
||||
create:
|
||||
file_too_large: 文件过大,最大允许 %{max_size}。
|
||||
invalid_file_type: 文件类型无效。
|
||||
csv_uploaded: CSV 已成功上传。
|
||||
ndjson_uploaded: NDJSON 文件已成功上传。
|
||||
pdf_too_large: PDF 过大,最大允许 %{max_size}。
|
||||
pdf_processing: PDF 正在处理中。
|
||||
invalid_pdf: PDF 文件无效。
|
||||
duplicate_pdf_unavailable: 暂时无法检查重复 PDF。
|
||||
document_too_large: 文档过大,最大允许 %{max_size}。
|
||||
invalid_document_file_type: 文档文件类型无效。
|
||||
document_uploaded: 文档已成功上传。
|
||||
document_upload_failed: 文档上传失败。
|
||||
invalid_ndjson_file_type: NDJSON 文件类型无效。
|
||||
document_provider_not_configured: 文档处理提供商尚未配置。
|
||||
show:
|
||||
finalize_upload: 完成上传
|
||||
finalize_mappings: 完成映射
|
||||
errors:
|
||||
custom_column_requires_inflow: 自定义列需要选择流入金额列。
|
||||
document_types:
|
||||
bank_statement: 银行账单
|
||||
credit_card_statement: 信用卡账单
|
||||
investment_statement: 投资账单
|
||||
financial_document: 财务文档
|
||||
contract: 合同
|
||||
other: 其他文档
|
||||
unknown: 未知文档
|
||||
pdf_import:
|
||||
processing_title: 正在处理 PDF
|
||||
processing_description: 正在分析文档并提取交易。
|
||||
check_status: 检查状态
|
||||
back_to_dashboard: 返回仪表盘
|
||||
failed_title: 处理失败
|
||||
failed_description: 无法处理此文档。请重试或查看日志。
|
||||
try_again: 重试
|
||||
delete_import: 删除导入
|
||||
complete_title: 文档已分析
|
||||
complete_description: 文档分析完成,可以继续审核导入结果。
|
||||
document_type_label: 文档类型
|
||||
source_statement: 来源账单
|
||||
summary_label: 摘要
|
||||
email_sent_notice: 处理完成后会发送邮件通知。
|
||||
back_to_imports: 返回导入
|
||||
unknown_state_title: 未知状态
|
||||
unknown_state_description: 此导入处于未知状态。请刷新页面或查看日志。
|
||||
processing_failed_with_message: "%{message}"
|
||||
processing_failed_generic: 处理失败:%{error}
|
||||
ready_for_review_title: 可供审核
|
||||
ready_for_review_description: 已提取 %{count} 条交易,请审核后发布。
|
||||
transactions_extracted: 已提取交易
|
||||
transactions_extracted_count:
|
||||
one: "%{count} 笔交易"
|
||||
other: "%{count} 笔交易"
|
||||
select_account: 导入到账户
|
||||
select_account_placeholder: 选择账户...
|
||||
select_account_hint: 选择这些交易要导入的账户。
|
||||
no_accounts: 没有可用账户。
|
||||
create_account: 创建账户
|
||||
save_account: 保存
|
||||
publish_transactions:
|
||||
one: 发布 %{count} 笔交易
|
||||
other: 发布 %{count} 笔交易
|
||||
review_transactions: 审核交易
|
||||
select_account_to_continue: 请选择账户后继续。
|
||||
unknown_document_type: 未知
|
||||
|
||||
@@ -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}!
|
||||
|
||||
@@ -11,6 +11,7 @@ zh-CN:
|
||||
existing_user_added: 用户已添加到您的家庭中。
|
||||
failure: 无法发送邀请
|
||||
success: 邀请已成功发送
|
||||
existing_user_has_family_data: 此用户已有家庭数据,无法接受该邀请。
|
||||
destroy:
|
||||
failure: 移除邀请时出现问题。
|
||||
not_authorized: 您无权管理邀请。
|
||||
|
||||
@@ -10,6 +10,7 @@ zh-CN:
|
||||
home: 主页
|
||||
reports: 报表
|
||||
transactions: 交易
|
||||
goals: 目标
|
||||
auth:
|
||||
existing_account: 已经有账户?
|
||||
no_account: 新加入 %{product_name}?
|
||||
|
||||
@@ -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: 编辑贷款详情
|
||||
|
||||
@@ -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 账户都已设置完成。
|
||||
|
||||
@@ -34,6 +34,7 @@ zh-CN:
|
||||
merchant: 商户
|
||||
actions: 操作
|
||||
source: 来源
|
||||
import: 导入商户
|
||||
family_merchant:
|
||||
edit: 编辑
|
||||
delete: 删除
|
||||
|
||||
@@ -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: 欢迎!你的账户已创建。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>"
|
||||
|
||||
@@ -11,3 +11,5 @@ zh-CN:
|
||||
update:
|
||||
invalid_token: 验证令牌无效。
|
||||
success: 密码已重置成功。
|
||||
disabled: Sure 密码重置已禁用。请通过身份提供商重置密码。
|
||||
sso_only_user: 你的账户使用 SSO 登录。请联系管理员管理凭据。
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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: 估算市值
|
||||
|
||||
@@ -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: 收入
|
||||
|
||||
@@ -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 密钥失败
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!:
|
||||
|
||||
@@ -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: 已通过环境变量配置同步设置。
|
||||
|
||||
@@ -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: 通行密钥或安全密钥已移除。
|
||||
|
||||
@@ -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、保存,然后关联同步到账户。
|
||||
|
||||
@@ -36,3 +36,7 @@ zh-CN:
|
||||
expense: 支出
|
||||
income: 收入
|
||||
transfer: 转账
|
||||
preview: 预览
|
||||
sync_toast:
|
||||
message: 有新数据可用
|
||||
refresh: 刷新
|
||||
|
||||
@@ -155,5 +155,5 @@ zh-CN:
|
||||
other: 已勾稽 %{count} 笔重复待处理交易
|
||||
stale_pending_status:
|
||||
message:
|
||||
one: 1 笔待处理交易早于 %{days} 天
|
||||
one: "%{count} 笔待处理交易早于 %{days} 天"
|
||||
other: "%{count} 笔待处理交易早于 %{days} 天"
|
||||
|
||||
@@ -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: 处理贡献时出现问题。请重试。
|
||||
|
||||
@@ -11,3 +11,4 @@ zh-CN:
|
||||
explanation: "“%{tag_name}”将从交易及其他可标记实体中移除。您也可以在下述选项中选择一个新标签进行重新分配,而不是保留为未标记状态。"
|
||||
replacement_tag_prompt: 选择新标签
|
||||
tag: 标签
|
||||
delete_and_reassign: 删除并重新分配
|
||||
|
||||
@@ -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: 所有标签已删除
|
||||
|
||||
@@ -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: 拆分交易的一部分
|
||||
|
||||
@@ -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: 转账已更新
|
||||
|
||||
@@ -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: 超级管理员
|
||||
|
||||
@@ -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: 此日期的账户价值
|
||||
|
||||
@@ -32,3 +32,4 @@ zh-CN:
|
||||
trend: 走势
|
||||
unknown: 未知
|
||||
year: 年份
|
||||
edit_account_details: 编辑账户详情
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
285
mobile/lib/widgets/sure_button.dart
Normal file
285
mobile/lib/widgets/sure_button.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
208
mobile/test/widgets/sure_button_test.dart
Normal file
208
mobile/test/widgets/sure_button_test.dart
Normal 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
28
test/components/DS/disclosure_test.rb
Normal file
28
test/components/DS/disclosure_test.rb
Normal 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
|
||||
@@ -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)
|
||||
|
||||
34
test/controllers/settings/securities_controller_test.rb
Normal file
34
test/controllers/settings/securities_controller_test.rb
Normal 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
|
||||
@@ -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)
|
||||
|
||||
36
test/jobs/application_job_test.rb
Normal file
36
test/jobs/application_job_test.rb
Normal 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
|
||||
32
test/jobs/destroy_job_test.rb
Normal file
32
test/jobs/destroy_job_test.rb
Normal 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
|
||||
@@ -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!
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user