From 8ccc434b3d16bcf03bcabe12b8c6766ebb380259 Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Wed, 3 Jun 2026 11:30:51 +0200 Subject: [PATCH 01/20] feat(ai): Anthropic native PDF processing (3/5) (#1985) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ai): add Anthropic provider with chat parity (1/5) Introduces Provider::Anthropic alongside Provider::Openai, implementing the LlmConcept chat_response contract over the official anthropic Ruby SDK. Batch ops, PDF, and RAG land in follow-up PRs. - Provider::Anthropic uses Messages API for sync and streaming responses - ChatConfig builds requests with ephemeral prompt-cache markers on the system prompt and the last tool definition - MessageFormatter reconstructs multi-turn history (text + tool_use + tool_result blocks) from raw Message records, including the paired user-role tool_result turn Anthropic requires after every tool_use - ChatParser maps Anthropic Message into the shared ChatResponse Data - Registry, Setting, User, Chat default model wired for ANTHROPIC_* envs and Setting.anthropic_*; LLM_PROVIDER selects between providers - Responder forwards raw conversation_history (Array) so providers without hosted conversation state can rebuild context - OpenAI provider accepts and ignores the new kwarg (no behavior change) Tests cover provider init, model gating, MessageFormatter for all turn shapes, ChatConfig request building (max_tokens, system cache, tool conversion), ChatParser for text / tool_use / mixed blocks, Registry discovery, and mocked chat_response success / error / function_request paths. Live VCR cassettes recorded in a follow-up with a real key. Stacked PRs: 2/5 batch ops + cost ledger, 3/5 PDF, 4/5 pgvector RAG, 5/5 settings UI + disclosure. * fix(ai): address PR review on Anthropic provider foundation Surface fixes raised by Codex + CodeRabbit on PR 1/5: - Provider::Anthropic#chat_response now accepts (and ignores) a `messages:` kwarg. Assistant::Responder passes both `messages:` (OpenAI-shape) and `conversation_history:` (raw Message records) for cross-provider parity, so the previous signature raised ArgumentError on the first chat turn through the Anthropic provider. - Provider::Anthropic#supports_model? bypasses the `claude` prefix gate when a custom base_url is configured, mirroring the OpenAI provider. Bedrock-shaped IDs like `anthropic.claude-sonnet-4-5-20250929-v1:0` and `claude-opus-4@20250514` are otherwise rejected by Assistant::Provided#get_model_provider and the chat dies. - Setting.anthropic_access_token is now in EncryptedSettingFields::ENCRYPTED_FIELDS so the Anthropic API key is encrypted at rest like every other provider secret. Previously plaintext while siblings (openai_access_token, twelve_data_api_key, external_assistant_token) were ciphertext. - Chat.default_model falls back to whichever provider is actually configured. Previously, with LLM_PROVIDER=anthropic but no Anthropic credentials, the default model resolved to a Claude ID that no registered provider supported, so chats failed even when OpenAI was fully configured. Adds Provider::{Anthropic,Openai}#configured? class methods for the readable callsite. - Provider::Anthropic.effective_model uses `ENV["ANTHROPIC_MODEL"].presence || Setting.anthropic_model` so the Setting lookup is only performed when the env var is absent — the previous `ENV.fetch(KEY, default)` evaluated the default arg eagerly on every call. - Provider::Anthropic::ChatConfig#anthropic_input_schema strips both `:strict` and `"strict"` keys so JSON-decoded schemas with string keys cannot leak the OpenAI-only flag through to Anthropic. Test coverage added: supports_model? bypass on custom endpoints, chat_response messages: kwarg compatibility, default_model fallback in the three credential combinations, configured? against ENV + Setting, strict-flag stripping for both key types, and a `Setting.expects(:anthropic_model).never` assertion proving the ENV-precedence test now exercises the lazy path. All 4365 tests pass (1 pre-existing libvips env error unrelated). * test(chat): make default_model tests resilient to ENV model overrides CodeRabbit flagged on PR review: the new default_model tests asserted against Provider::*::DEFAULT_MODEL, but Chat.default_model actually returns Provider::*.effective_model.presence (which reads OPENAI_MODEL / ANTHROPIC_MODEL from the environment). With either env var set, the tests would fail intermittently even though routing was correct. - New default_model tests now assert against the provider's effective_model directly, so they verify the routing decision (which provider's value wins) without coupling to the constant. - Pre-existing "creates with default model" assertions had the same brittleness; switch them to compare against Chat.default_model so the chosen model is whatever the env / Setting cascade resolves to. Verified by running `ANTHROPIC_MODEL=claude-haiku-4-5 OPENAI_MODEL=gpt-4o bin/rails test test/models/chat_test.rb` — 16 runs, 0 failures (previously 2 pre-existing failures + 0 from the new tests). * fix(ai): address local review on Anthropic foundation - Provider::Anthropic#supports_pdf_processing? bypasses prefix gate for custom endpoints, mirroring supports_model? - Provider::Anthropic#initialize raises Error when custom_endpoint? AND model.blank?, parity with Provider::Openai - stream_chat_response captures partial usage on mid-stream errors and records it via the new on_partial callback so chat_response can skip the duplicate error row in the outer rescue - safe_accumulated_message swallows the secondary failure when the SDK cannot reconstruct a snapshot - langfuse_client memoizes properly (||= instead of =) so repeated calls don't churn Langfuse instances - MessageFormatter sorts tool_calls by created_at then id so the message array is deterministic across replays; skips tool_calls missing both provider_call_id and provider_id rather than sending `id: nil` and getting rejected by Anthropic - Setting.anthropic_access_token default falls back through ENV["ANTHROPIC_API_KEY"].presence (was missing .presence, so an empty-string env value bled through) - User#openai_configured? / #anthropic_configured? delegate to the Provider::* class methods — single source of truth - Assistant::Responder renames the OpenAI-shape history builder conversation_history → openai_messages_payload so the kwarg name matches the local method name (messages: openai_messages_payload, conversation_history: chat_message_records) - Assistant::Builtin stale-history comment updated to reference both builders Adds a streaming chat_response test using ad-hoc subclasses of the SDK event types so the case/when dispatch matches via is_a? without stubbing class-level === behavior. * test(ai): add Anthropic tool_use round-trip + multi-tool turn coverage Addresses @jjmata's "worth confirming" note on PR #1983: tool-use turns from prior assistant messages must round-trip correctly when retrieved from the database. - New `ChatParser → ToolCall::Function → MessageFormatter` test walks the full path: Anthropic response with a tool_use block → ChatFunctionRequest → ToolCall::Function.from_function_request → persisted on the AssistantMessage → MessageFormatter rebuild on the next turn. Asserts the original `tool_use.id` is preserved end-to-end as both `tool_use.id` and the paired `tool_result.tool_use_id`, and that the original `input` hash and serialized result content survive. - New multi-tool assistant turn test confirms two tool_use blocks on a single assistant message render as two tool_use blocks followed by two paired tool_result blocks in a single user-role follow-up, matching Anthropic's required alternation. Both tests exercise the existing PR1 code without behavior changes. * test(ai): require "ostruct" explicitly in Anthropic provider tests OpenStruct is moving out of Ruby's default load path (warning in 3.4+, removed in 3.5+). Tests work today because ActiveSupport transitively loads it, but that's incidental. Match the existing convention in test/controllers/settings/hostings_controller_test.rb which explicitly requires ostruct for the same reason. * fix(ai): sanitize Langfuse warn logs, normalize tool_use.input, dedup history fetch Addresses three open CodeRabbit findings on PR #1983. - Provider::Anthropic Langfuse rescue branches no longer include `e.full_message` in `Rails.logger.warn`. `full_message` bundles the backtrace + cause chain and on some SDK error types includes the serialized request/response payload (prompt, model output). Logs now report `#{e.class}: #{e.message}` only. Three sites: create_langfuse_trace, log_langfuse_generation, upsert_langfuse_trace. Note: Provider::Openai has the same pattern (copy-pasted source) — harmonization deferred to a follow-up cleanup PR; this commit fixes only the Anthropic provider to keep PR scope tight. - MessageFormatter#parse_arguments now coerces any non-Hash parsed result to `{}`. Anthropic's Messages API requires `tool_use.input` to be a JSON object (map); a stored ToolCall::Function record whose arguments parse to a scalar, bool, or array (corrupt row, legacy data, cross-provider bleed) would otherwise produce a payload the API rejects. Normal flow stores Hash arguments end-to-end so the fix is defensive — adds 2 tests covering scalar/array JSON strings and non-String non-Hash inputs. - Assistant::Responder dedups the chat-history fetch. The previous layout fired two near-identical `chat.messages.where(...).includes( :tool_calls).ordered` queries per LLM turn (one for the OpenAI-shape payload, one for the raw-records kwarg). A new memoized `complete_chat_messages` fetches once; `chat_message_records` filters out the current message via `Array#reject`, `openai_messages_payload` iterates the cached array unchanged. One SQL query per turn instead of two. Memoization scope = single Responder instance (per LLM call), so cache invalidation is not a concern. All 4370 tests pass (1 pre-existing libvips env error unrelated). Rubocop + brakeman clean. * fix(ci): replace sk-ant- prefixed test placeholders Pipelock secret scanner pattern-matches `sk-ant-*` as a real Anthropic API key and fails the PR security-scan check. Test stubs and ClimateControl env values used `sk-ant-test`, `sk-ant-from-setting`, `sk-ant-x`, `sk-ant-y` as obvious placeholders, but the scanner does not care about value entropy. Switched to `fake-anthropic-key-*` / `fake-token-*` strings so the scanner stops flagging them. No production code touched, no behavior change — Provider::Anthropic still accepts any non-blank token. * feat(ai): add Anthropic batch ops + LLM cost ledger (2/5) Implements auto_categorize, auto_detect_merchants, and enhance_provider_merchants on Provider::Anthropic via forced tool calls, plus the cost-ledger plumbing they need. - Provider::Anthropic::AutoCategorizer, AutoMerchantDetector, ProviderMerchantEnhancer each define a single output tool whose input_schema mirrors the desired output, then force the model to call it via tool_choice: { type: "tool", name: ..., disable_parallel_tool_use: true }. Anthropic guarantees the tool_use.input matches the schema, so there is no JSON parsing fragility, no tag stripping, and no json_object/json_schema fallback ladders. - Concerns::UsageRecorder mirrors the OpenAI sibling but persists cache_creation_input_tokens / cache_read_input_tokens to dedicated columns instead of metadata. - Migration adds cache_creation_tokens, cache_read_tokens (nullable integers) to llm_usages. OpenAI rows leave them null. - LlmUsage::PRICING gains Claude 4.x rows (opus-4-7 $15/$75, sonnet-4-6 $3/$15, haiku-4-5 $1/$5 per MTok). infer_provider returns "anthropic" for claude-* via the existing exact/prefix lookup. - Provider::Anthropic#chat_response now persists cache columns directly rather than stashing them in metadata. - 25-transaction batch cap mirrors the OpenAI provider so the cost ledger sees the same shape regardless of which provider ran a batch. Tests cover the forced-tool-call path, null/None normalization, case-insensitive merchant matching, the missing-tool_use error path, and Anthropic-specific pricing + provider inference on LlmUsage. Stacked on #1983 (PR 1/5). 3/5 PDF + vision next. * fix(ai): attribute Bedrock model IDs to anthropic + clean nil enum - LlmUsage.infer_provider now returns "anthropic" for Bedrock / Vertex shaped IDs (anthropic.* and anthropic/*), so cost-ledger filtering by provider stays correct even when no per-MTok rate is stored. Previously these IDs fell through to the "openai" default. - AutoCategorizer drops the redundant nil sentinel from the category_name enum — the union type [string, null] already permits null, and some JSON Schema validators reject nil literals inside enum arrays. * test(ai): require "ostruct" in Anthropic batch op tests Same rationale as the PR1 ostruct fix — explicit require so the tests don't depend on ActiveSupport's transitive load when Ruby 3.5+ removes OpenStruct from the default load path. * feat(ai): Anthropic native PDF processing (3/5) Implements process_pdf and extract_bank_statement on Provider::Anthropic using the native `document` content block — no rasterization, no text pre-extraction. - Provider::Anthropic::PdfProcessor classifies the document, summarizes it, and extracts statement metadata via a forced report_document_analysis tool whose input_schema mirrors the existing Provider::Openai output (document_type from Import::DOCUMENT_TYPES, summary, extracted_data). - Provider::Anthropic::BankStatementExtractor returns the same { transactions, period, account_holder, account_number, bank_name, opening_balance, closing_balance } shape via report_bank_statement so downstream pdf_import code is provider-agnostic. - Both attach the PDF as { type: "document", source: { type: "base64", media_type: "application/pdf", data: } } — Claude 3.5+ / 4.x accept this natively (up to 32MB / 100 pages). No pdf-reader, no pdftoppm, no chunking for typical statements. - supports_pdf_processing? (introduced in PR 1) already returns true for claude-* models, gating process_pdf with a clear error otherwise. - Cost ledger rows are persisted via the shared UsageRecorder concern, including cache_creation/cache_read tokens. Tests verify the document block shape, tool_choice forcing, normalized document_type for unknown classifications, transaction normalization (date / amount / reference → notes), and the missing-tool_use error path. Blank pdf_content raises before any client call. Stacked on #1984 (PR 2/5). 4/5 pgvector RAG next. * fix(ai): guard PDF size + surface bank-statement truncation - PdfProcessor and BankStatementExtractor raise upfront when pdf_content.bytesize exceeds MAX_PDF_BYTES (32 MB, matching Anthropic's hard limit). Previously a 100 MB PDF would be base64-encoded (~133 MB) and packed into the JSON body before the API rejected it — peak heap ~270 MB per Sidekiq worker. - BankStatementExtractor inspects response.stop_reason; when the model hit max_tokens it logs a warning and flags result[:truncated] so downstream callers know the transaction list may be incomplete. - ISO date pattern added to statement_period_start/end schema in PdfProcessor so the model can't return "March 2026" — Anthropic enforces the regex via the tool's input_schema. Tests cover the size guard (raises before any client.messages call), truncated-result flagging, and the warning log path. * test(ai): require "ostruct" in Anthropic PDF tests Match the explicit ostruct require added in PR1/PR2 — same Ruby 3.5+ load-path reason. * fix(llm-usage): include Anthropic cache tokens in estimated_cost calculate_cost only priced prompt + completion tokens, so estimated_cost under-reported every cached call — the cache_creation/cache_read columns this PR added were tracked but never billed. Verified against the Anthropic dashboard: a cached chat turn billed $0.05 but the ledger recorded $0.038; the gap was exactly the unpriced cache tokens. Price them relative to the input rate (Anthropic: cache write 1.25x, read 0.1x) and thread the cache counts from both recorders (chat + batch). OpenAI rows leave the columns null (treated as 0), so they're unaffected. Ledger now reproduces the dashboard ($0.054 for the test turn). * chore(ai): guard chat usage double-record; flag deferred Anthropic batch wiring - Hardening: guard the success-path record_llm_usage with `unless partial_usage_recorded` so a future change that emits partial usage on a normal stream can't silently double-bill (the symptom investigated in the #1984 review). No behavior change today — on_partial only fires from the mid-stream-error rescue, which re-raises past this line. - Notice: the family auto-categorize / merchant-detect / merchant-enhance flows still hardcode get_provider(:openai). Provider::Anthropic now implements those batch ops but they aren't wired into the family flows yet — documented with TODOs at each site for the follow-up. * chore(ai): point family-flow TODOs at tracking issue #2113 * chore(ai): flag deferred Anthropic PDF wiring (TODO #2113) The PDF import + bank-statement-extract flows hardcode get_provider(:openai). Provider::Anthropic implements process_pdf / extract_bank_statement (this PR) but they aren't wired into these paths yet — documented with TODOs at each site. Tracked in #2113 (broadened to cover batch ops + PDF). * chore(anthropic-pdf): drop redundant strip_heredoc; document no-dedup - The squiggly heredoc (<<~) already strips indentation, so the trailing .strip_heredoc was a no-op in both PDF extractors. - Document why BankStatementExtractor intentionally does NOT deduplicate (unlike the OpenAI extractor): we send the whole PDF as one native document block, so there are no overlapping-chunk artifacts to dedupe, and deduping would wrongly merge legitimate same-day, same-amount transactions. * fix(anthropic): cap PDF bytes below the base64-encoded request limit Anthropic's 32 MB limit is on the Messages request body, and the PDF is sent base64-encoded (~4/3 larger) alongside the JSON envelope, so a 32 MB raw PDF encodes to ~42 MB and is rejected. Cap the raw bytes at 3/4 of the request budget minus a 1 MB envelope reserve (~23 MiB). Addresses Codex review on #1985. --- .../function/import_bank_statement.rb | 2 + app/models/pdf_import.rb | 4 + app/models/provider/anthropic.rb | 43 +++- .../anthropic/bank_statement_extractor.rb | 229 ++++++++++++++++++ .../provider/anthropic/pdf_processor.rb | 185 ++++++++++++++ .../bank_statement_extractor_test.rb | 141 +++++++++++ .../provider/anthropic/pdf_processor_test.rb | 126 ++++++++++ 7 files changed, 728 insertions(+), 2 deletions(-) create mode 100644 app/models/provider/anthropic/bank_statement_extractor.rb create mode 100644 app/models/provider/anthropic/pdf_processor.rb create mode 100644 test/models/provider/anthropic/bank_statement_extractor_test.rb create mode 100644 test/models/provider/anthropic/pdf_processor_test.rb diff --git a/app/models/assistant/function/import_bank_statement.rb b/app/models/assistant/function/import_bank_statement.rb index dee54602f..d3bf86dec 100644 --- a/app/models/assistant/function/import_bank_statement.rb +++ b/app/models/assistant/function/import_bank_statement.rb @@ -93,6 +93,8 @@ class Assistant::Function::ImportBankStatement < Assistant::Function end # Extract transactions from the PDF using provider + # TODO(#2113): hardcoded to OpenAI. Provider::Anthropic implements + # extract_bank_statement (PR #1985); this should honor Setting.llm_provider. provider = Provider::Registry.get_provider(:openai) unless provider return { diff --git a/app/models/pdf_import.rb b/app/models/pdf_import.rb index 1e1f494ef..69c74cb9e 100644 --- a/app/models/pdf_import.rb +++ b/app/models/pdf_import.rb @@ -89,6 +89,8 @@ class PdfImport < Import end def process_with_ai + # TODO(#2113): hardcoded to OpenAI. Provider::Anthropic implements + # process_pdf (PR #1985); this should honor Setting.llm_provider. provider = Provider::Registry.get_provider(:openai) raise "AI provider not configured" unless provider raise "AI provider does not support PDF processing" unless provider.supports_pdf_processing? @@ -115,6 +117,8 @@ class PdfImport < Import def extract_transactions return unless statement_with_transactions? + # TODO(#2113): hardcoded to OpenAI. Provider::Anthropic implements + # extract_bank_statement (PR #1985); this should honor Setting.llm_provider. provider = Provider::Registry.get_provider(:openai) raise "AI provider not configured" unless provider diff --git a/app/models/provider/anthropic.rb b/app/models/provider/anthropic.rb index 8e2e1fa50..ab05a0e0b 100644 --- a/app/models/provider/anthropic.rb +++ b/app/models/provider/anthropic.rb @@ -155,11 +155,50 @@ class Provider::Anthropic < Provider end def process_pdf(pdf_content:, model: "", family: nil) - raise Error, "process_pdf not yet implemented for Provider::Anthropic" + with_provider_response do + effective_model = model.presence || @default_model + raise Error, "Model does not support PDF processing: #{effective_model}" unless supports_pdf_processing?(model: effective_model) + + trace = create_langfuse_trace( + name: "anthropic.process_pdf", + input: { pdf_size: pdf_content&.bytesize } + ) + + result = PdfProcessor.new( + client, + model: effective_model, + pdf_content: pdf_content, + langfuse_trace: trace, + family: family + ).process + + upsert_langfuse_trace(trace: trace, output: result.to_h) + + result + end end def extract_bank_statement(pdf_content:, model: "", family: nil) - raise Error, "extract_bank_statement not yet implemented for Provider::Anthropic" + with_provider_response do + effective_model = model.presence || @default_model + + trace = create_langfuse_trace( + name: "anthropic.extract_bank_statement", + input: { pdf_size: pdf_content&.bytesize } + ) + + result = BankStatementExtractor.new( + client: client, + pdf_content: pdf_content, + model: effective_model, + langfuse_trace: trace, + family: family + ).extract + + upsert_langfuse_trace(trace: trace, output: { transaction_count: result[:transactions].size }) + + result + end end def chat_response( diff --git a/app/models/provider/anthropic/bank_statement_extractor.rb b/app/models/provider/anthropic/bank_statement_extractor.rb new file mode 100644 index 000000000..e91d44b47 --- /dev/null +++ b/app/models/provider/anthropic/bank_statement_extractor.rb @@ -0,0 +1,229 @@ +class Provider::Anthropic::BankStatementExtractor + include Provider::Anthropic::Concerns::UsageRecorder + + TOOL_NAME = "report_bank_statement".freeze + + # Mirrors Provider::Anthropic::PdfProcessor::MAX_PDF_BYTES. + MAX_PDF_BYTES = 32 * 1024 * 1024 + + attr_reader :client, :model, :pdf_content, :langfuse_trace, :family + + def initialize(client:, model:, pdf_content:, langfuse_trace: nil, family: nil) + @client = client + @model = model + @pdf_content = pdf_content + @langfuse_trace = langfuse_trace + @family = family + end + + def extract + raise Provider::Anthropic::Error, "PDF content is required" if pdf_content.blank? + if pdf_content.bytesize > MAX_PDF_BYTES + raise Provider::Anthropic::Error, + "PDF exceeds Anthropic's 32 MB limit (#{pdf_content.bytesize} bytes)" + end + + span = langfuse_trace&.span(name: "extract_bank_statement_api_call", input: { + model: model, + pdf_size: pdf_content.bytesize + }) + + response = client.messages.create( + model: model, + max_tokens: max_tokens, + system_: instructions, + messages: [ { role: "user", content: user_content } ], + tools: [ output_tool ], + tool_choice: { type: "tool", name: TOOL_NAME, disable_parallel_tool_use: true } + ) + + parsed = extract_tool_input(response) + result = build_result(parsed) + + truncated = stop_reason(response) == :max_tokens + if truncated + Rails.logger.warn( + "[BankStatementExtractor] response truncated by max_tokens — extracted #{result[:transactions].size} " \ + "transactions but more may be present in the statement. Raise ANTHROPIC_MAX_TOKENS or chunk the PDF." + ) + result[:truncated] = true + end + + record_usage(model, response.usage, operation: "extract_bank_statement", metadata: { + pdf_size: pdf_content.bytesize, + transaction_count: result[:transactions].size, + truncated: truncated + }) + + span&.end(output: { transaction_count: result[:transactions].size }, usage: usage_hash(response.usage)) + result + rescue => e + span&.end(output: { error: e.message }, level: "ERROR") + record_usage_error(model, operation: "extract_bank_statement", error: e, metadata: { pdf_size: pdf_content&.bytesize }) + raise + end + + private + def max_tokens + ENV.fetch("ANTHROPIC_MAX_TOKENS", 4096).to_i + end + + def user_content + [ + { + type: "document", + source: { + type: "base64", + media_type: "application/pdf", + data: Base64.strict_encode64(pdf_content) + } + }, + { + type: "text", + text: "Extract every transaction from this bank statement and return them via the report_bank_statement tool." + } + ] + end + + def output_tool + { + name: TOOL_NAME, + description: "Return the full set of transactions and statement metadata extracted from the PDF.", + input_schema: { + type: "object", + properties: { + bank_name: { type: [ "string", "null" ] }, + account_holder: { type: [ "string", "null" ] }, + account_number: { type: [ "string", "null" ], description: "Typically last 4 digits only." }, + statement_period: { + type: "object", + properties: { + start_date: { type: [ "string", "null" ], description: "YYYY-MM-DD" }, + end_date: { type: [ "string", "null" ], description: "YYYY-MM-DD" } + }, + required: [], + additionalProperties: false + }, + opening_balance: { type: [ "number", "null" ] }, + closing_balance: { type: [ "number", "null" ] }, + transactions: { + type: "array", + description: "Every transaction in the statement, in document order.", + items: { + type: "object", + properties: { + date: { type: "string", description: "YYYY-MM-DD" }, + description: { type: "string" }, + amount: { type: "number", description: "Negative for debits / expenses, positive for credits / deposits." }, + reference: { type: [ "string", "null" ] }, + category: { type: [ "string", "null" ] } + }, + required: [ "date", "description", "amount" ], + additionalProperties: false + } + } + }, + required: [ "transactions" ], + additionalProperties: false + } + } + end + + def instructions + <<~INSTRUCTIONS + Extract bank statement data from the attached PDF and return the result via the report_bank_statement tool. + + Rules: + - Extract EVERY transaction in document order + - Negative amounts for debits / expenses, positive for credits / deposits + - Dates in YYYY-MM-DD + - Use null for any field you cannot read; do not invent values + INSTRUCTIONS + end + + def stop_reason(response) + raw = response.respond_to?(:stop_reason) ? response.stop_reason : nil + raw.to_s.to_sym if raw + end + + def extract_tool_input(response) + tool_use = Array(response.content).find { |block| block_type(block) == :tool_use } + raise Provider::Anthropic::Error, "Model did not invoke #{TOOL_NAME}" unless tool_use + + input = block_input(tool_use) + input = JSON.parse(input) if input.is_a?(String) + input + end + + def build_result(parsed) + # Intentionally NOT deduplicated, unlike Provider::Openai's extractor. That + # one chunks the PDF text with overlap and must drop transactions repeated + # across adjacent chunks. We send the whole PDF as a single native document + # block — no chunk artifacts — so deduping here would wrongly merge + # legitimate same-day, same-amount rows (e.g. two identical purchases). + # Preserve every transaction the model returns. + transactions = Array(parsed["transactions"] || parsed[:transactions]).map { |t| normalize_transaction(t) }.compact + + { + transactions: transactions, + period: { + start_date: dig_period(parsed, :start_date), + end_date: dig_period(parsed, :end_date) + }, + account_holder: parsed["account_holder"] || parsed[:account_holder], + account_number: parsed["account_number"] || parsed[:account_number], + bank_name: parsed["bank_name"] || parsed[:bank_name], + opening_balance: parsed["opening_balance"] || parsed[:opening_balance], + closing_balance: parsed["closing_balance"] || parsed[:closing_balance] + } + end + + def dig_period(parsed, key) + period = parsed["statement_period"] || parsed[:statement_period] + return nil unless period.is_a?(Hash) + period[key.to_s] || period[key] + end + + def normalize_transaction(txn) + return nil unless txn.is_a?(Hash) + + { + date: parse_date(txn["date"] || txn[:date]), + amount: parse_amount(txn["amount"] || txn[:amount]), + name: txn["description"] || txn[:description] || txn["name"] || txn[:name], + category: txn["category"] || txn[:category], + notes: txn["reference"] || txn[:reference] + } + end + + def parse_date(date_str) + return nil if date_str.blank? + Date.parse(date_str.to_s).strftime("%Y-%m-%d") + rescue ArgumentError + nil + end + + def parse_amount(amount) + return nil if amount.nil? + return amount.to_f if amount.is_a?(Numeric) + amount.to_s.gsub(/[^0-9.\-]/, "").to_f + end + + def block_type(block) + raw = block.respond_to?(:type) ? block.type : block[:type] || block["type"] + raw.to_s.to_sym + end + + def block_input(block) + block.respond_to?(:input) ? block.input : (block[:input] || block["input"]) + end + + def usage_hash(raw_usage) + return {} unless raw_usage + { + "input_tokens" => raw_usage.input_tokens.to_i, + "output_tokens" => raw_usage.output_tokens.to_i, + "total_tokens" => raw_usage.input_tokens.to_i + raw_usage.output_tokens.to_i + } + end +end diff --git a/app/models/provider/anthropic/pdf_processor.rb b/app/models/provider/anthropic/pdf_processor.rb new file mode 100644 index 000000000..dc6dc2c96 --- /dev/null +++ b/app/models/provider/anthropic/pdf_processor.rb @@ -0,0 +1,185 @@ +class Provider::Anthropic::PdfProcessor + include Provider::Anthropic::Concerns::UsageRecorder + + TOOL_NAME = "report_document_analysis".freeze + + # Anthropic enforces a 32 MB limit on the whole Messages *request body*, and + # the PDF travels base64-encoded (~4/3 larger) inside that body alongside the + # JSON envelope (instructions, tool schema). So a 32 MB raw PDF would encode + # to ~42 MB and be rejected. Cap the raw bytes at 3/4 of the request budget, + # minus a generous envelope reserve, so the encoded request stays under the + # limit. Guarding upstream also avoids base64-encoding an over-size blob in + # vain (peak heap before the API would reject it). + MAX_REQUEST_BYTES = 32 * 1024 * 1024 + REQUEST_ENVELOPE_BYTES = 1 * 1024 * 1024 + MAX_PDF_BYTES = (MAX_REQUEST_BYTES - REQUEST_ENVELOPE_BYTES) * 3 / 4 + + attr_reader :client, :model, :pdf_content, :langfuse_trace, :family + + def initialize(client, model:, pdf_content:, langfuse_trace: nil, family: nil) + @client = client + @model = model + @pdf_content = pdf_content + @langfuse_trace = langfuse_trace + @family = family + end + + def process + raise Provider::Anthropic::Error, "PDF content is required" if pdf_content.blank? + if pdf_content.bytesize > MAX_PDF_BYTES + raise Provider::Anthropic::Error, + "PDF is too large (#{pdf_content.bytesize} bytes); base64-encoded it would exceed Anthropic's 32 MB request limit" + end + + span = langfuse_trace&.span(name: "process_pdf_api_call", input: { + model: model, + pdf_size: pdf_content&.bytesize + }) + + response = client.messages.create( + model: model, + max_tokens: max_tokens, + system_: instructions, + messages: [ { role: "user", content: user_content } ], + tools: [ output_tool ], + tool_choice: { type: "tool", name: TOOL_NAME, disable_parallel_tool_use: true } + ) + + parsed = extract_tool_input(response) + result = build_result(parsed) + + record_usage(model, response.usage, operation: "process_pdf", metadata: { pdf_size: pdf_content.bytesize }) + + span&.end(output: result.to_h, usage: usage_hash(response.usage)) + result + rescue => e + span&.end(output: { error: e.message }, level: "ERROR") + record_usage_error(model, operation: "process_pdf", error: e, metadata: { pdf_size: pdf_content&.bytesize }) + raise + end + + private + PdfProcessingResult = Provider::LlmConcept::PdfProcessingResult + + def max_tokens + ENV.fetch("ANTHROPIC_MAX_TOKENS", 4096).to_i + end + + def user_content + [ + { + type: "document", + source: { + type: "base64", + media_type: "application/pdf", + data: Base64.strict_encode64(pdf_content) + } + }, + { + type: "text", + text: "Analyze the attached document and return the result via the report_document_analysis tool." + } + ] + end + + def output_tool + { + name: TOOL_NAME, + description: "Return the structured analysis of the attached document.", + input_schema: { + type: "object", + properties: { + document_type: { + type: "string", + enum: Import::DOCUMENT_TYPES, + description: "Classification of the document." + }, + summary: { + type: "string", + description: "Concise human-readable summary of the document." + }, + extracted_data: { + type: "object", + properties: { + institution_name: { type: [ "string", "null" ] }, + statement_period_start: { type: [ "string", "null" ], pattern: "^\\d{4}-\\d{2}-\\d{2}$", description: "YYYY-MM-DD or null" }, + statement_period_end: { type: [ "string", "null" ], pattern: "^\\d{4}-\\d{2}-\\d{2}$", description: "YYYY-MM-DD or null" }, + transaction_count: { type: [ "integer", "null" ] }, + opening_balance: { type: [ "number", "null" ] }, + closing_balance: { type: [ "number", "null" ] }, + currency: { type: [ "string", "null" ] }, + account_holder: { type: [ "string", "null" ] } + }, + required: [], + additionalProperties: false + } + }, + required: [ "document_type", "summary", "extracted_data" ], + additionalProperties: false + } + } + end + + def instructions + <<~INSTRUCTIONS + You analyze financial documents. For the attached PDF, classify the document type, + summarize it, and extract key metadata. Return the result via the report_document_analysis tool. + + Classification options: + - bank_statement: bank account statements (incl. mobile money / digital wallets) + - credit_card_statement: credit card statements + - investment_statement: brokerage / investment statements + - financial_document: tax forms, receipts, invoices, financial reports + - contract: legal agreements, loans, terms of service + - other: anything else + + Rules: + - Be factual; only report what is clearly visible + - If a field is unclear/redacted, return null for it + - Do not invent figures or names you cannot read + - For statements with many transactions, return the count rather than enumerating them + INSTRUCTIONS + end + + def extract_tool_input(response) + tool_use = Array(response.content).find { |block| block_type(block) == :tool_use } + raise Provider::Anthropic::Error, "Model did not invoke #{TOOL_NAME}" unless tool_use + + input = block_input(tool_use) + input = JSON.parse(input) if input.is_a?(String) + input + end + + def build_result(parsed) + PdfProcessingResult.new( + summary: parsed["summary"] || parsed[:summary], + document_type: normalize_document_type(parsed["document_type"] || parsed[:document_type]), + extracted_data: parsed["extracted_data"] || parsed[:extracted_data] || {} + ) + end + + def normalize_document_type(doc_type) + return "other" if doc_type.blank? + + normalized = doc_type.to_s.strip.downcase.gsub(/\s+/, "_") + Import::DOCUMENT_TYPES.include?(normalized) ? normalized : "other" + end + + def block_type(block) + raw = block.respond_to?(:type) ? block.type : block[:type] || block["type"] + raw.to_s.to_sym + end + + def block_input(block) + block.respond_to?(:input) ? block.input : (block[:input] || block["input"]) + end + + def usage_hash(raw_usage) + return {} unless raw_usage + { + "input_tokens" => raw_usage.input_tokens.to_i, + "output_tokens" => raw_usage.output_tokens.to_i, + "total_tokens" => raw_usage.input_tokens.to_i + raw_usage.output_tokens.to_i + } + end +end diff --git a/test/models/provider/anthropic/bank_statement_extractor_test.rb b/test/models/provider/anthropic/bank_statement_extractor_test.rb new file mode 100644 index 000000000..203246221 --- /dev/null +++ b/test/models/provider/anthropic/bank_statement_extractor_test.rb @@ -0,0 +1,141 @@ +require "test_helper" +require "ostruct" + +class Provider::Anthropic::BankStatementExtractorTest < ActiveSupport::TestCase + setup do + @pdf_content = "%PDF-1.4 fake bytes".b + end + + test "sends PDF as native document and returns normalized transactions + metadata" do + fake_response = build_response(content: [ + tool_use_block( + id: "toolu_1", + name: "report_bank_statement", + input: { + "bank_name" => "Bank of Example", + "account_holder" => "Jane Doe", + "account_number" => "1234", + "statement_period" => { "start_date" => "2026-03-01", "end_date" => "2026-03-31" }, + "opening_balance" => 1000.0, + "closing_balance" => 1500.0, + "transactions" => [ + { "date" => "2026-03-05", "description" => "Coffee", "amount" => -4.5 }, + { "date" => "2026-03-15", "description" => "Salary", "amount" => 3000.0, "reference" => "Payroll Mar" } + ] + } + ) + ]) + client = stub_client(fake_response) + + result = Provider::Anthropic::BankStatementExtractor.new( + client: client, + model: "claude-sonnet-4-6", + pdf_content: @pdf_content + ).extract + + assert_equal "Bank of Example", result[:bank_name] + assert_equal "Jane Doe", result[:account_holder] + assert_equal "1234", result[:account_number] + assert_equal "2026-03-01", result[:period][:start_date] + assert_equal "2026-03-31", result[:period][:end_date] + assert_equal 1000.0, result[:opening_balance] + assert_equal 1500.0, result[:closing_balance] + + assert_equal 2, result[:transactions].size + txn1 = result[:transactions].first + assert_equal "2026-03-05", txn1[:date] + assert_equal "Coffee", txn1[:name] + assert_equal(-4.5, txn1[:amount]) + + txn2 = result[:transactions].last + assert_equal "Salary", txn2[:name] + assert_equal 3000.0, txn2[:amount] + assert_equal "Payroll Mar", txn2[:notes] + end + + test "raises when pdf_content is blank" do + err = assert_raises(Provider::Anthropic::Error) do + Provider::Anthropic::BankStatementExtractor.new( + client: mock, + model: "claude-sonnet-4-6", + pdf_content: nil + ).extract + end + assert_match(/PDF content is required/i, err.message) + end + + test "raises when model omits the tool call" do + fake_response = build_response(content: [ OpenStruct.new(type: :text, text: "no tool") ]) + client = stub_client(fake_response) + + err = assert_raises(Provider::Anthropic::Error) do + Provider::Anthropic::BankStatementExtractor.new( + client: client, + model: "claude-sonnet-4-6", + pdf_content: @pdf_content + ).extract + end + assert_match(/did not invoke report_bank_statement/i, err.message) + end + + test "raises before API call when pdf_content exceeds the 32 MB limit" do + oversized = "a".b * (Provider::Anthropic::BankStatementExtractor::MAX_PDF_BYTES + 1) + client = mock + client.expects(:messages).never + + err = assert_raises(Provider::Anthropic::Error) do + Provider::Anthropic::BankStatementExtractor.new( + client: client, + model: "claude-sonnet-4-6", + pdf_content: oversized + ).extract + end + assert_match(/exceeds Anthropic's 32 MB limit/i, err.message) + end + + test "flags result as truncated when stop_reason is max_tokens" do + fake_response = build_response( + content: [ + tool_use_block( + id: "toolu_1", + name: "report_bank_statement", + input: { "transactions" => [ { "date" => "2026-03-05", "description" => "Coffee", "amount" => -4.5 } ] } + ) + ] + ) + fake_response.stop_reason = :max_tokens + client = stub_client(fake_response) + + Rails.logger.expects(:warn).with(regexp_matches(/truncated by max_tokens/i)) + + result = Provider::Anthropic::BankStatementExtractor.new( + client: client, + model: "claude-sonnet-4-6", + pdf_content: @pdf_content + ).extract + + assert_equal true, result[:truncated] + end + + private + def stub_client(response) + messages = mock + messages.stubs(:create).returns(response) + client = mock + client.stubs(:messages).returns(messages) + client + end + + def build_response(content:, usage: { input_tokens: 1500, output_tokens: 400 }) + OpenStruct.new( + id: "msg_test", + model: "claude-sonnet-4-6", + content: content, + usage: OpenStruct.new(input_tokens: usage[:input_tokens], output_tokens: usage[:output_tokens]) + ) + end + + def tool_use_block(id:, name:, input:) + OpenStruct.new(type: :tool_use, id: id, name: name, input: input) + end +end diff --git a/test/models/provider/anthropic/pdf_processor_test.rb b/test/models/provider/anthropic/pdf_processor_test.rb new file mode 100644 index 000000000..d2cdbb3a7 --- /dev/null +++ b/test/models/provider/anthropic/pdf_processor_test.rb @@ -0,0 +1,126 @@ +require "test_helper" +require "ostruct" + +class Provider::Anthropic::PdfProcessorTest < ActiveSupport::TestCase + setup do + @pdf_content = "%PDF-1.4 fake bytes".b + end + + test "sends PDF as native document content block and parses tool response" do + fake_response = build_response(content: [ + tool_use_block( + id: "toolu_1", + name: "report_document_analysis", + input: { + "document_type" => "bank_statement", + "summary" => "Bank of Example, Mar 2026 statement.", + "extracted_data" => { + "institution_name" => "Bank of Example", + "statement_period_start" => "2026-03-01", + "statement_period_end" => "2026-03-31", + "transaction_count" => 42, + "opening_balance" => 1000.0, + "closing_balance" => 1500.0, + "currency" => "USD", + "account_holder" => "Account Holder" + } + } + ) + ]) + captured = nil + client = stub_client(fake_response) { |params| captured = params } + + result = Provider::Anthropic::PdfProcessor.new( + client, + model: "claude-sonnet-4-6", + pdf_content: @pdf_content + ).process + + document_block = captured[:messages].first[:content].first + assert_equal "document", document_block[:type] + assert_equal "application/pdf", document_block[:source][:media_type] + assert_equal "base64", document_block[:source][:type] + assert_equal Base64.strict_encode64(@pdf_content), document_block[:source][:data] + + assert_equal "report_document_analysis", captured[:tool_choice][:name] + assert captured[:tool_choice][:disable_parallel_tool_use] + + assert_equal "bank_statement", result.document_type + assert_equal "Bank of Example, Mar 2026 statement.", result.summary + assert_equal 42, result.extracted_data["transaction_count"] + end + + test "normalizes unknown document_type to other" do + fake_response = build_response(content: [ + tool_use_block( + id: "toolu_2", + name: "report_document_analysis", + input: { + "document_type" => "alien_invasion_form", + "summary" => "Unknown.", + "extracted_data" => {} + } + ) + ]) + client = stub_client(fake_response) + + result = Provider::Anthropic::PdfProcessor.new( + client, + model: "claude-sonnet-4-6", + pdf_content: @pdf_content + ).process + + assert_equal "other", result.document_type + end + + test "raises when pdf_content is blank" do + err = assert_raises(Provider::Anthropic::Error) do + Provider::Anthropic::PdfProcessor.new( + mock, + model: "claude-sonnet-4-6", + pdf_content: "" + ).process + end + assert_match(/PDF content is required/i, err.message) + end + + test "raises before any API call when pdf_content exceeds the base64-adjusted cap" do + oversized = "a".b * (Provider::Anthropic::PdfProcessor::MAX_PDF_BYTES + 1) + client = mock + client.expects(:messages).never + + err = assert_raises(Provider::Anthropic::Error) do + Provider::Anthropic::PdfProcessor.new( + client, + model: "claude-sonnet-4-6", + pdf_content: oversized + ).process + end + assert_match(/32 MB request limit/i, err.message) + end + + private + def stub_client(response) + messages = mock + messages.expects(:create).with do |params| + yield(params) if block_given? + true + end.returns(response) + client = mock + client.stubs(:messages).returns(messages) + client + end + + def build_response(content:, usage: { input_tokens: 800, output_tokens: 200 }) + OpenStruct.new( + id: "msg_test", + model: "claude-sonnet-4-6", + content: content, + usage: OpenStruct.new(input_tokens: usage[:input_tokens], output_tokens: usage[:output_tokens]) + ) + end + + def tool_use_block(id:, name:, input:) + OpenStruct.new(type: :tool_use, id: id, name: name, input: input) + end +end From 7017b6340e64aeaa8349cb379074cc539647c12f Mon Sep 17 00:00:00 2001 From: Jeff <158072326+jeffrey701@users.noreply.github.com> Date: Wed, 3 Jun 2026 02:53:15 -0700 Subject: [PATCH 02/20] fix(helm): normalize appVersion to strip leading v (#2050) (#2156) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(helm): normalize appVersion to strip leading v (#2050) Releases triggered on a tag like `v0.7.1-rc.1` end up writing `appVersion: "v0.7.1-rc.1"` into Chart.yaml / the published index.yaml, but the Docker image is pushed to GHCR without the leading `v` (`ghcr.io/we-promise/sure:0.7.1-rc.1`). Flux CD / any consumer that pulls the chart then fails with `ImagePullBackoff` against `v0.7.1-rc.1` (a tag that doesn't exist). `normalize_version` is already applied to `CHART_VERSION`; route the two tag-derived `APP_VERSION` paths through the same helper so the appVersion matches the published image tag. Closes #2050 * chore(ci): bind helm-publish version inputs to step env (#2050) @coderabbitai (zizmor) flagged that the version-resolve step expanded ${{ inputs.chart_version }} and ${{ inputs.app_version }} directly into bash, which is a template-injection vector — a malicious caller of this reusable workflow could inject shell via an input like '; rm -rf … #'. Bind both inputs to step env (CHART_VERSION_INPUT, APP_VERSION_INPUT) and reference them as shell variables in the conditionals. Behaviour is unchanged; the values just arrive through the env table instead of the runner's template pass. --------- Co-authored-by: jeffrey701 --- .github/workflows/helm-publish.yml | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/workflows/helm-publish.yml b/.github/workflows/helm-publish.yml index 510c64049..0588162c0 100644 --- a/.github/workflows/helm-publish.yml +++ b/.github/workflows/helm-publish.yml @@ -40,6 +40,12 @@ jobs: - name: Resolve chart and app versions id: version shell: bash + # Bind workflow inputs to env so the values arrive as shell variables + # instead of being interpolated verbatim by the `${{ }}` runner pass. + # zizmor flags the direct expansion as a template-injection risk. + env: + CHART_VERSION_INPUT: ${{ inputs.chart_version }} + APP_VERSION_INPUT: ${{ inputs.app_version }} run: | set -euo pipefail @@ -48,18 +54,24 @@ jobs: echo "${raw#v}" } - if [ -n "${{ inputs.chart_version }}" ]; then - CHART_VERSION="$(normalize_version "${{ inputs.chart_version }}")" + if [ -n "$CHART_VERSION_INPUT" ]; then + CHART_VERSION="$(normalize_version "$CHART_VERSION_INPUT")" elif [[ "${GITHUB_REF_TYPE}" == "tag" && "${GITHUB_REF_NAME}" == v* ]]; then CHART_VERSION="$(normalize_version "${GITHUB_REF_NAME}")" else CHART_VERSION="0.0.0-nightly.$(date -u +'%Y%m%d.%H%M%S')" fi - if [ -n "${{ inputs.app_version }}" ]; then - APP_VERSION="${{ inputs.app_version }}" + # Normalize APP_VERSION the same way CHART_VERSION is — appVersion + # must match the OCI image tag in GHCR, which is published without a + # leading `v`. Without this, a release on tag `v0.7.1-rc.1` writes + # `appVersion: "v0.7.1-rc.1"` into Chart.yaml / index.yaml, and Helm + # then fails to pull `ghcr.io/we-promise/sure:v0.7.1-rc.1` (the real + # tag is `0.7.1-rc.1`). See #2050. + if [ -n "$APP_VERSION_INPUT" ]; then + APP_VERSION="$(normalize_version "$APP_VERSION_INPUT")" elif [[ "${GITHUB_REF_TYPE}" == "tag" && "${GITHUB_REF_NAME}" == v* ]]; then - APP_VERSION="${GITHUB_REF_NAME}" + APP_VERSION="$(normalize_version "${GITHUB_REF_NAME}")" else APP_VERSION="${CHART_VERSION}" fi From ae251d0a6e429cf1b81356972825465ffccb4feb Mon Sep 17 00:00:00 2001 From: glorydavid03023 Date: Wed, 3 Jun 2026 04:55:56 -0500 Subject: [PATCH 03/20] feat(i18n): complete Spanish (es) locale coverage for provider & transaction views (#2159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several view namespaces only shipped a subset of the 14-locale baseline (ca, de, en, es, fr, hu, nb, nl, pl, pt-BR, ro, tr, zh-CN, zh-TW), leaving Spanish users on English fallbacks for these screens. This adds Spanish translations for every namespace that had an English source but was missing `es`, bringing them to baseline parity: - account_sharings, account_statements, splits, pending_duplicate_merges, messages - Provider connections: binance_items, brex_items, ibkr_items, kraken_items, sophtron_items All files mirror the en.yml key structure exactly (839 keys, verified with key-parity and %{interpolation} placeholder checks) and reuse existing Spanish terminology already established in the repo (cuenta, sincronizado, credenciales, conexión, vincular, Saldo; informal "tú"). Co-authored-by: Claude Opus 4.8 (1M context) --- config/locales/views/account_sharings/es.yml | 29 ++ .../locales/views/account_statements/es.yml | 116 +++++++ config/locales/views/binance_items/es.yml | 75 +++++ config/locales/views/brex_items/es.yml | 277 ++++++++++++++++ config/locales/views/ibkr_items/es.yml | 92 +++++ config/locales/views/kraken_items/es.yml | 85 +++++ config/locales/views/messages/es.yml | 6 + .../views/pending_duplicate_merges/es.yml | 21 ++ config/locales/views/sophtron_items/es.yml | 313 ++++++++++++++++++ config/locales/views/splits/es.yml | 47 +++ 10 files changed, 1061 insertions(+) create mode 100644 config/locales/views/account_sharings/es.yml create mode 100644 config/locales/views/account_statements/es.yml create mode 100644 config/locales/views/binance_items/es.yml create mode 100644 config/locales/views/brex_items/es.yml create mode 100644 config/locales/views/ibkr_items/es.yml create mode 100644 config/locales/views/kraken_items/es.yml create mode 100644 config/locales/views/messages/es.yml create mode 100644 config/locales/views/pending_duplicate_merges/es.yml create mode 100644 config/locales/views/sophtron_items/es.yml create mode 100644 config/locales/views/splits/es.yml diff --git a/config/locales/views/account_sharings/es.yml b/config/locales/views/account_sharings/es.yml new file mode 100644 index 000000000..c05fe3015 --- /dev/null +++ b/config/locales/views/account_sharings/es.yml @@ -0,0 +1,29 @@ +--- +es: + account_sharings: + show: + title: Compartir cuenta + subtitle: Controla quién puede ver esta cuenta e interactuar con ella + member: Miembro + permission: Permiso + shared: Compartida + no_members: No hay otros miembros en tu %{moniker} con quienes compartir + permissions: + full_control: Control total + full_control_description: Puede ver, editar y gestionar transacciones + read_write: Puede anotar + read_write_description: Puede categorizar, etiquetar y añadir notas + read_only: Solo lectura + read_only_description: Solo puede ver los datos de la cuenta + save: Guardar ajustes de uso compartido + owner_label: "Propietario: %{name}" + shared_with_count: + one: Compartida con 1 miembro + other: "Compartida con %{count} miembros" + include_in_finances: Incluir en mis presupuestos e informes + exclude_from_finances: Excluir de mis presupuestos e informes + finance_toggle_description: Tener en cuenta esta cuenta en tu patrimonio neto, presupuestos e informes + update: + success: Ajustes de uso compartido actualizados + not_owner: Solo el propietario de la cuenta puede gestionar el uso compartido + finance_toggle_success: Preferencia de inclusión financiera actualizada diff --git a/config/locales/views/account_statements/es.yml b/config/locales/views/account_statements/es.yml new file mode 100644 index 000000000..3486f844d --- /dev/null +++ b/config/locales/views/account_statements/es.yml @@ -0,0 +1,116 @@ +--- +es: + account_statements: + account_tab: + coverage_title: Cobertura de extractos + coverage_description: Meses históricos respaldados por extractos subidos y comprobaciones de saldo. + coverage_range: "%{start} - %{end}" + empty: Aún no hay extractos vinculados a esta cuenta. + open_inbox: Bandeja de entrada + statements_title: Extractos + year_label: Año de cobertura + balance: + unknown: Desconocido + coverage: + status: + ambiguous: Ambiguo + covered: Cubierto + duplicate: Duplicado + mismatched: No coincide + missing: Faltante + not_expected: No esperado + create: + duplicates: + one: Se omitió 1 extracto duplicado. + other: "Se omitieron %{count} extractos duplicados." + invalid_file_type: Sube un extracto en PDF, CSV o XLSX que no supere el límite de tamaño. + no_files: Selecciona al menos un archivo de extracto. + success: + one: 1 extracto subido. + other: "%{count} extractos subidos." + destroy: + failure: No se pudo eliminar el extracto. + success: Extracto eliminado. + form: + account_upload: Subir extracto + files_hint: PDF, CSV o XLSX. Máximo %{max_size} MB por archivo. + files_label: Archivos de extracto + inbox_upload: Subir + index: + account_label: Cuenta + confidence: "Coincidencia del %{confidence}" + empty_linked: Aún no hay extractos vinculados. + empty_unmatched: La bandeja de entrada de extractos está vacía. + leave_unmatched: Dejar sin asignar + linked_title: Extractos vinculados + no_suggestion: Sin sugerencia + storage_used: Almacenamiento usado + title: Bóveda de extractos + unmatched_title: Bandeja de entrada sin asignar + upload_description: Sube extractos a la bandeja de entrada o elige una cuenta para vincularlos de inmediato. + upload_title: Subir extractos + link: + no_account: Elige una cuenta antes de vincular este extracto. + success: Extracto vinculado a %{account}. + period: + unknown: Periodo desconocido + reconciliation: + checks: + closing_balance: Saldo de cierre + opening_balance: Saldo de apertura + period_movement: Movimiento del periodo + unknown_check: Comprobación desconocida + matched: Coincide + mismatched: No coincide + unavailable: Sin comprobar + reject: + success: Coincidencia de extracto rechazada. + show: + account_label: Cuenta + account_last4_hint: Últimos cuatro dígitos de la cuenta + account_name_hint: Pista del nombre de la cuenta + closing_balance: Saldo de cierre + currency: Moneda + delete: Eliminar + difference: Diferencia + download: Descargar + institution_name_hint: Pista de la entidad + ledger_amount: Registro de Sure + linked_to: Vinculado a %{account}. + linking_title: Vínculo de cuenta + link_suggestion: Sugerencia de vínculo + metadata_title: Metadatos del extracto + no_suggestion: Aún no hay sugerencia de cuenta. + opening_balance: Saldo de apertura + period_end_on: Fin del periodo + period_start_on: Inicio del periodo + reconciliation_title: Conciliación + reconciliation_unavailable: Añade un periodo de extracto y un saldo de apertura o cierre, y asegúrate de que Sure tenga historial de saldos para esas fechas. + reject: Rechazar + save: Guardar extracto + statement_amount: Extracto + suggested_account: La cuenta sugerida es %{account} (confianza del %{confidence}). + title: Extracto + unlink: Desvincular + unmatched_account: Bandeja de entrada sin asignar + unknown_value: Desconocido + status: + linked: Vinculado + rejected: Rechazado + unmatched: Sin asignar + table: + account: Cuenta + actions: Acciones + download: Descargar + file: Archivo + link_suggestion: Sugerencia de vínculo + period: Periodo + reconciliation: Conciliación + reject: Rechazar sugerencia + suggestion: Sugerencia + unlink: Desvincular + view: Ver + unlink: + success: Extracto devuelto a la bandeja de entrada sin asignar. + update: + success: Extracto actualizado. diff --git a/config/locales/views/binance_items/es.yml b/config/locales/views/binance_items/es.yml new file mode 100644 index 000000000..2b83aa0bd --- /dev/null +++ b/config/locales/views/binance_items/es.yml @@ -0,0 +1,75 @@ +--- +es: + binance_items: + create: + default_name: Binance + success: ¡Conexión con Binance establecida con éxito! Tu cuenta se está sincronizando. + update: + success: Configuración de Binance actualizada correctamente. + destroy: + success: Conexión de Binance programada para su eliminación. + setup_accounts: + title: Importar cuenta de Binance + subtitle: Selecciona qué carteras quieres seguir + instructions: Selecciona las carteras de Binance que quieres importar. Solo se muestran las carteras con saldos. + no_accounts: Se han importado todas las cuentas. + accounts_count: + one: "%{count} cuenta disponible" + other: "%{count} cuentas disponibles" + select_all: Seleccionar todas + import_selected: Importar seleccionadas + cancel: Cancelar + creating: Importando... + complete_account_setup: + success: + one: "Se ha importado %{count} cuenta" + other: "Se han importado %{count} cuentas" + none_selected: No se ha seleccionado ninguna cuenta + no_accounts: No hay cuentas para importar + binance_item: + provider_name: Binance + syncing: Sincronizando... + reconnect: Es necesario actualizar las credenciales + deletion_in_progress: Eliminando... + sync_status: + no_accounts: No se han encontrado cuentas + all_synced: + one: "%{count} cuenta sincronizada" + other: "%{count} cuentas sincronizadas" + partial_sync: "%{linked_count} sincronizadas, %{unlinked_count} necesitan configuración" + status: "Sincronizado hace %{timestamp}" + status_with_summary: "Sincronizado hace %{timestamp} - %{summary}" + status_never: Nunca sincronizado + update_credentials: Actualizar credenciales + delete: Eliminar + no_accounts_title: No se han encontrado cuentas + no_accounts_message: Tu cartera de Binance aparecerá aquí después de la sincronización. + setup_needed: Cuenta lista para importar + setup_description: Selecciona qué carteras de Binance quieres seguir. + setup_action: Importar cuenta + import_accounts_menu: Importar cuenta + stale_rate_warning: "El saldo es aproximado: el tipo de cambio exacto para %{date} no estaba disponible. Se actualizará en la próxima sincronización." + select_existing_account: + title: Vincular cuenta de Binance + no_accounts_found: No se han encontrado cuentas de Binance. + wait_for_sync: Espera a que Binance termine de sincronizar + check_provider_health: Comprueba que tus credenciales de la API de Binance sean válidas + currently_linked_to: "Actualmente vinculada a: %{account_name}" + link: Vincular + cancel: Cancelar + link_existing_account: + success: Vinculado correctamente a la cuenta de Binance + errors: + only_manual: Solo las cuentas manuales pueden vincularse a Binance + invalid_binance_account: Cuenta de Binance no válida + binance_item: + syncer: + checking_credentials: Comprobando credenciales... + credentials_invalid: Credenciales de la API no válidas. Comprueba tu clave de API y tu secreto. + importing_accounts: Importando cuentas desde Binance... + checking_configuration: Comprobando la configuración de la cuenta... + accounts_need_setup: + one: "%{count} cuenta necesita configuración" + other: "%{count} cuentas necesitan configuración" + processing_accounts: Procesando los datos de la cuenta... + calculating_balances: Calculando saldos... diff --git a/config/locales/views/brex_items/es.yml b/config/locales/views/brex_items/es.yml new file mode 100644 index 000000000..f2f3949a2 --- /dev/null +++ b/config/locales/views/brex_items/es.yml @@ -0,0 +1,277 @@ +--- +es: + brex_items: + default_connection_name: Conexión de Brex + account_metadata: + provider: Brex + separator: " • " + kinds: + cash: Efectivo + card: Tarjeta + statuses: + ACTIVE: Activa + active: Activa + CLOSED: Cerrada + closed: Cerrada + frozen: Congelada + FROZEN: Congelada + create: + success: Conexión de Brex creada correctamente + default_card_name: Tarjeta Brex + default_cash_name: "Brex Cash %{id}" + destroy: + success: Conexión de Brex eliminada + index: + title: Conexiones de Brex + institution_summary: + none: No hay entidades conectadas + one: "%{name}" + count: + one: "%{count} entidad" + other: "%{count} entidades" + sync_status: + no_accounts: No se han encontrado cuentas + all_synced: + one: "%{count} cuenta sincronizada" + other: "%{count} cuentas sincronizadas" + partial_setup: "%{synced} sincronizadas, %{pending} necesitan configuración" + api_error: + common_issues: "Problemas habituales:" + expired_credentials: Genera un nuevo token de API en Brex. + expired_credentials_label: "Credenciales caducadas:" + heading: No se puede conectar con Brex + invalid_token: Comprueba tu token de API en los Ajustes del proveedor. + invalid_token_label: "Token de API no válido:" + network: Comprueba tu conexión a internet. + network_label: "Problema de red:" + permissions: Asegúrate de que tu token tenga los ámbitos de solo lectura necesarios para cuentas y transacciones. + permissions_label: "Permisos insuficientes:" + service: Es posible que la API de Brex no esté disponible temporalmente. + service_label: "Servicio caído:" + settings_link: Comprobar los Ajustes del proveedor + title: Error de conexión con Brex + errors: + unexpected_error: Se ha producido un error inesperado. Inténtalo de nuevo más tarde. + entries: + default_name: Transacción de Brex + loading: + loading_message: Cargando cuentas de Brex... + loading_title: Cargando + link_accounts: + all_already_linked: + one: "La cuenta seleccionada (%{names}) ya está vinculada" + other: "Las %{count} cuentas seleccionadas ya están vinculadas: %{names}" + api_error: "Error de la API: %{message}" + invalid_account_names: + one: "No se puede vincular una cuenta con el nombre en blanco" + other: "No se pueden vincular %{count} cuentas con los nombres en blanco" + invalid_account_type: Tipo de cuenta de Brex no admitido + link_failed: No se pudieron vincular las cuentas + no_accounts_selected: Selecciona al menos una cuenta + no_api_token: No se ha encontrado el token de API de Brex. Configúralo en los Ajustes del proveedor. + partial_invalid: "Se vincularon %{created_count} cuenta(s) correctamente, %{already_linked_count} cuenta(s) ya estaban vinculadas, %{invalid_count} cuenta(s) tenían nombres no válidos" + partial_success: "Se vincularon %{created_count} cuenta(s) correctamente. %{already_linked_count} cuenta(s) ya estaban vinculadas: %{already_linked_names}" + select_connection: Elige una conexión de Brex antes de vincular cuentas. + success: + one: "Se ha vinculado %{count} cuenta correctamente" + other: "Se han vinculado %{count} cuentas correctamente" + brex_item: + accounts_need_setup: Las cuentas necesitan configuración + delete: Eliminar conexión + deletion_in_progress: eliminación en curso... + error: Error + no_accounts_description: Esta conexión aún no tiene cuentas vinculadas. + no_accounts_title: Sin cuentas + setup_action: Configurar nuevas cuentas + setup_description: "%{linked} de %{total} cuentas vinculadas. Elige los tipos de cuenta para tus cuentas de Brex recién importadas." + setup_needed: Nuevas cuentas listas para configurar + status: "Sincronizado hace %{timestamp}" + status_never: Nunca sincronizado + status_with_summary: "Sincronizado hace %{timestamp} - %{summary}" + syncing: Sincronizando... + total: Total + unlinked: Sin vincular + provider_panel: + accounts_link: Cuentas + add_connection: Añadir conexión de Brex + base_url_label: URL base (opcional) + base_url_placeholder: https://api.brex.com + configured_html: "Configurado y listo para usar. Visita la pestaña %{accounts_link} para gestionar y configurar las cuentas." + connection_name_label: Nombre de la conexión + connection_name_placeholder: Cuenta corriente de empresa + default_connection_name: Conexión de Brex + disconnect_label: "Desconectar %{name}" + disconnect_confirm: "¿Desconectar %{name}?" + encryption_warning: + title: El cifrado de la base de datos no está configurado + message: Configura las claves de cifrado de Active Record antes de añadir tokens de Brex en producción. Sin claves de cifrado, Sure almacena las credenciales y las instantáneas del proveedor Brex en texto plano, como ocurre con otros registros de proveedores. + instructions: + copy_token_html: "Copia el token y añádelo abajo como una conexión con nombre. Sure almacena el token únicamente para sincronizar esta familia." + create_token: "Crea un token de API con estos ámbitos de solo lectura: accounts.cash.readonly, accounts.card.readonly, transactions.cash.readonly, transactions.card.readonly" + open_tokens: Ve a los ajustes de tokens de desarrollador/API de Brex de la empresa que quieres conectar + sign_in_html: "Visita %{link} e inicia sesión en la cuenta que quieres conectar" + keep_token_placeholder: Déjalo en blanco para conservar el token actual + not_configured: No configurado + sandbox_note_html: "Usa una conexión con nombre distinta para cada empresa/token de API de Brex que quieras sincronizar. Deja la URL base en blanco para producción. El entorno de staging está limitado a pruebas aprobadas por Brex y no funciona con tokens de clientes." + setup_accounts: Configurar cuentas + setup_title: "Instrucciones de configuración:" + sync: Sincronizar + token_label: Token + token_placeholder: Pega el token aquí + update_connection: Actualizar conexión + provider_connection: + default_description: Conectar a tu cuenta de Brex + default_name: Brex + description: "Conectar mediante %{name}" + name: "Brex - %{name}" + select_accounts: + accounts_selected: cuentas seleccionadas + api_error: "Error de la API: %{message}" + cancel: Cancelar + configure_name_in_brex: "No se puede importar: configura el nombre de la cuenta en Brex" + description: Selecciona las cuentas que quieres vincular a tu cuenta de %{product_name}. + link_accounts: Vincular cuentas seleccionadas + no_accounts_found: No se han encontrado cuentas. Comprueba la configuración de tu token de API. + no_api_token: No se ha encontrado el token de API de Brex. Configúralo en los Ajustes del proveedor. + no_credentials_configured: Configura primero tu token de API de Brex en los Ajustes del proveedor. + no_name_placeholder: "(Sin nombre)" + select_connection: Elige una conexión de Brex en los Ajustes del proveedor. + title: Seleccionar cuentas de Brex + unexpected_error: Se ha producido un error inesperado. Inténtalo de nuevo más tarde. + select_existing_account: + account_already_linked: Esta cuenta ya está vinculada a un proveedor + all_accounts_already_linked: Todas las cuentas de Brex ya están vinculadas + api_error: "Error de la API: %{message}" + cancel: Cancelar + configure_name_in_brex: "No se puede importar: configura el nombre de la cuenta en Brex" + description: Selecciona una cuenta de Brex para vincularla a esta cuenta. Las transacciones se sincronizarán y se eliminarán los duplicados automáticamente. + link_account: Vincular cuenta + no_account_specified: No se ha especificado ninguna cuenta + no_accounts_found: No se han encontrado cuentas de Brex. Comprueba la configuración de tu token de API. + no_api_token: No se ha encontrado el token de API de Brex. Configúralo en los Ajustes del proveedor. + no_credentials_configured: Configura primero tu token de API de Brex en los Ajustes del proveedor. + no_name_placeholder: "(Sin nombre)" + select_connection: Elige una conexión de Brex en los Ajustes del proveedor. + title: "Vincular %{account_name} con Brex" + unexpected_error: Se ha producido un error inesperado. Inténtalo de nuevo más tarde. + setup_required: + description: Antes de poder vincular cuentas de Brex, debes configurar tu token de API de Brex. + heading: Token de API no configurado + settings_link: Ir a los Ajustes del proveedor + setup_steps: "Pasos de configuración:" + steps: + enter_token: Introduce tu token de API de Brex + find_section_html: "Busca la sección Brex" + open_settings_html: "Ve a Ajustes > Proveedores" + return_to_link: Vuelve aquí para vincular tus cuentas + title: Configuración de Brex necesaria + subtype_select: + placeholder: + subtype: Selecciona el subtipo + type: Selecciona el tipo + link_existing_account: + account_already_linked: Esta cuenta ya está vinculada a un proveedor + api_error: "Error de la API: %{message}" + invalid_account_name: No se puede vincular una cuenta con el nombre en blanco + missing_parameters: Faltan parámetros obligatorios + no_account_specified: No se ha especificado ninguna cuenta + no_api_token: No se ha encontrado el token de API de Brex. Configúralo en los Ajustes del proveedor. + provider_account_already_linked: Esta cuenta de Brex ya está vinculada a otra cuenta + provider_account_not_found: No se ha encontrado la cuenta de Brex + select_connection: Elige una conexión de Brex antes de vincular cuentas. + success: "%{account_name} vinculada correctamente con Brex" + setup_accounts: + account_type_label: "Tipo de cuenta:" + all_accounts_linked: "Todas tus cuentas de Brex ya se han configurado." + api_error: "Error de la API: %{message}" + fetch_failed: "No se pudieron obtener las cuentas" + no_accounts_to_setup: "No hay cuentas para configurar" + no_api_token: No se ha encontrado el token de API de Brex. Configúralo en los Ajustes del proveedor. + account_types: + skip: Omitir esta cuenta + depository: Cuenta corriente o de ahorros + credit_card: Tarjeta de crédito + investment: Cuenta de inversión + loan: Préstamo o hipoteca + other_asset: Otro activo + subtype_labels: + depository: "Subtipo de cuenta:" + credit_card: "" + investment: "Tipo de inversión:" + loan: "Tipo de préstamo:" + other_asset: "" + subtype_messages: + credit_card: "Las tarjetas de crédito se configurarán automáticamente como cuentas de tarjeta de crédito." + other_asset: "No se necesitan opciones adicionales para Otros activos." + subtypes: + depository: + checking: Cuenta corriente + savings: Cuenta de ahorros + hsa: Cuenta de ahorro para la salud + cd: Certificado de depósito + money_market: Cuenta del mercado monetario + investment: + brokerage: Cuenta de bróker + pension: Pensión + retirement: Jubilación + "401k": "401(k)" + roth_401k: "Roth 401(k)" + "403b": "403(b)" + tsp: Thrift Savings Plan + "529_plan": "Plan 529" + hsa: Cuenta de ahorro para la salud + mutual_fund: Fondo de inversión + ira: IRA tradicional + roth_ira: Roth IRA + angel: Inversión ángel + loan: + mortgage: Hipoteca + student: Préstamo estudiantil + auto: Préstamo para automóvil + other: Otro préstamo + balance: Saldo + cancel: Cancelar + choose_account_type: "Elige el tipo de cuenta correcto para cada cuenta de Brex:" + create_accounts: Crear cuentas + creating_accounts: Creando cuentas... + historical_data_range: "Rango de datos históricos:" + subtitle: Elige los tipos de cuenta correctos para tus cuentas importadas + sync_start_date_help: Selecciona hasta qué fecha quieres sincronizar el historial de transacciones. Hay un máximo de 3 años de historial disponible. + sync_start_date_label: "Empezar a sincronizar transacciones desde:" + title: Configura tus cuentas de Brex + complete_account_setup: + all_skipped: "Se omitieron todas las cuentas. No se ha creado ninguna cuenta." + creation_failed: "No se pudieron crear las cuentas: %{error}" + creation_failed_count: "No se pudieron crear %{count} cuenta(s)." + no_accounts: "No hay cuentas para configurar." + partial_skipped: "Se crearon %{created_count} cuenta(s) correctamente; se omitieron %{skipped_count} cuenta(s)." + partial_success: "Se crearon %{created_count} cuenta(s) correctamente, pero %{failed_count} cuenta(s) fallaron." + success: "Se han creado %{count} cuenta(s) correctamente." + unexpected_error: Se ha producido un error inesperado. + sync: + success: Sincronización iniciada + syncer: + account_processing_failed: + one: "%{count} cuenta de Brex falló durante el procesamiento." + other: "%{count} cuentas de Brex fallaron durante el procesamiento." + account_sync_failed: + one: "No se pudo programar la sincronización de %{count} cuenta de Brex." + other: "No se pudo programar la sincronización de %{count} cuentas de Brex." + accounts_need_setup: + one: "%{count} cuenta necesita configuración..." + other: "%{count} cuentas necesitan configuración..." + accounts_failed: + one: "No se pudo importar %{count} cuenta de Brex." + other: "No se pudieron importar %{count} cuentas de Brex." + calculating_balances: Calculando saldos... + checking_account_configuration: Comprobando la configuración de la cuenta... + credentials_invalid: Token de API de Brex no válido o permisos de cuenta insuficientes + failed: La sincronización ha fallado. Inténtalo de nuevo o ponte en contacto con el soporte. + import_failed: La importación de Brex ha fallado. + importing_accounts: Importando cuentas desde Brex... + processing_transactions: Procesando transacciones... + transactions_failed: + one: "%{count} cuenta de Brex tuvo fallos al importar transacciones." + other: "%{count} cuentas de Brex tuvieron fallos al importar transacciones." + update: + success: Conexión de Brex actualizada diff --git a/config/locales/views/ibkr_items/es.yml b/config/locales/views/ibkr_items/es.yml new file mode 100644 index 000000000..857d308fb --- /dev/null +++ b/config/locales/views/ibkr_items/es.yml @@ -0,0 +1,92 @@ +--- +es: + providers: + ibkr: + name: Interactive Brokers + connection_description: Conecta un informe del Flex Web Service de Interactive Brokers + institution_name: Interactive Brokers + ibkr_items: + defaults: + name: Interactive Brokers + ibkr_item: + deletion_in_progress: Eliminación en curso + flex_web_service: Flex Web Service + syncing: Sincronizando + requires_update: Las credenciales requieren atención + error: Error + synced: Sincronizado hace %{time}. %{summary}. + never_synced: Nunca sincronizado. + setup_accounts: Configurar cuentas + delete: Eliminar + accounts_need_setup: Las cuentas necesitan configuración + accounts_need_setup_description: Algunas cuentas de IBKR deben vincularse a cuentas de Sure. + no_accounts_discovered: Aún no se han detectado cuentas de IBKR. + no_accounts_discovered_description: Ejecuta una sincronización después de configurar tu consulta Flex para detectar cuentas. + setup_accounts: + page_title: Configurar cuentas de Interactive Brokers + dialog_title: Configura tus cuentas de Interactive Brokers + subtitle: Selecciona qué cuentas de bróker de IBKR quieres vincular. + info_box: + title: Importación mediante consulta Flex de IBKR + items: + item_1: Posiciones con precios y cantidades actuales + item_2: Coste base por posición + item_3: Operaciones, dividendos, comisiones y depósitos o retiradas de efectivo + warning: La actividad histórica se limita al periodo del informe de la consulta Flex + status: + fetching_accounts: Obteniendo cuentas de Interactive Brokers... + no_accounts_found_title: No se han encontrado cuentas. + no_accounts_found_description: Sure no pudo encontrar ninguna cuenta de IBKR en el último informe Flex. + available_accounts: + title: Cuentas disponibles + account_type_investment: Inversión + account_summary: "%{account_type} • Saldo: %{balance}" + account_id: "ID de cuenta: %{account_id}" + link_existing: + description: O vincula una cuenta de IBKR detectada a una cuenta de inversión manual existente. + manual_account_option: "%{name} (%{balance})" + select_prompt: Selecciona una cuenta... + linked_accounts: + title: Ya vinculadas + linked_to_html: "Vinculada a: %{account}" + buttons: + refresh: Actualizar + cancel: Cancelar + back_to_settings: Volver a Ajustes + create_selected_accounts: Crear cuentas seleccionadas + link: Vincular + done: Hecho + sync_status: + no_accounts: Aún no se han detectado cuentas de IBKR + all_linked: + one: 1 cuenta vinculada + other: "%{count} cuentas vinculadas" + partial: "%{linked} vinculadas, %{unlinked} necesitan configuración" + select_existing_account: + title: Vincular cuenta de Interactive Brokers + no_accounts_available: Aún no hay cuentas de Interactive Brokers sin vincular disponibles. + run_sync_hint: "Ejecuta una sincronización desde Ajustes > Proveedores después de actualizar tu consulta Flex." + wait_for_sync: Espera a que termine la sincronización de detección de cuentas. + balance: Saldo + link: Vincular + cancel: Cancelar + create: + success: Interactive Brokers configurado correctamente. + update: + success: Configuración de Interactive Brokers actualizada correctamente. + destroy: + success: Conexión de Interactive Brokers programada para su eliminación. + select_accounts: + not_configured: Interactive Brokers no está configurado. + link_existing_account: + not_found: No se ha encontrado la cuenta o la configuración de Interactive Brokers. + only_manual_investment: Solo las cuentas de inversión manuales pueden vincularse a Interactive Brokers. + already_linked: Esta cuenta de Interactive Brokers ya está vinculada. + success: Vinculado correctamente a la cuenta de Interactive Brokers. + failed: No se pudo vincular la cuenta de Interactive Brokers. + complete_account_setup: + success: + one: Se ha creado %{count} cuenta de Interactive Brokers correctamente. + other: Se han creado %{count} cuentas de Interactive Brokers correctamente. + none_selected: No se ha seleccionado ninguna cuenta. + none_created: No se ha creado ninguna cuenta. diff --git a/config/locales/views/kraken_items/es.yml b/config/locales/views/kraken_items/es.yml new file mode 100644 index 000000000..26a3eaaf8 --- /dev/null +++ b/config/locales/views/kraken_items/es.yml @@ -0,0 +1,85 @@ +--- +es: + kraken_items: + provider_connection: + default_name: Kraken + default_description: Vincular a una cuenta de la plataforma de intercambio Kraken + name: "Kraken - %{name}" + description: "Vincular a %{name}" + create: + default_name: Kraken + success: Conexión con Kraken establecida con éxito. Tu cuenta de la plataforma se está sincronizando. + update: + success: Conexión de Kraken actualizada correctamente. + destroy: + success: Conexión de Kraken programada para su eliminación. + select_accounts: + select_connection: Elige una conexión de Kraken en los Ajustes del proveedor. + no_credentials_configured: Añade las credenciales de la API de Kraken antes de configurar las cuentas. + link_accounts: + select_connection: Elige una conexión de Kraken antes de vincular cuentas. + select_existing_account: + title: Vincular cuenta de Kraken + no_accounts_found: No se han encontrado cuentas de Kraken. + wait_for_sync: Espera a que Kraken termine de sincronizar. + check_provider_health: Comprueba que tus credenciales de la API de Kraken sean válidas. + link: Vincular + cancel: Cancelar + link_existing_account: + success: Vinculado correctamente a la cuenta de Kraken + select_connection: Elige una conexión de Kraken antes de vincular cuentas. + errors: + only_manual: Solo las cuentas manuales de plataforma de criptomonedas sin un vínculo de proveedor existente pueden vincularse a Kraken + invalid_kraken_account: Cuenta de Kraken no válida + kraken_account_already_linked: Esta cuenta de Kraken ya está vinculada + setup_accounts: + title: Importar cuenta de Kraken + subtitle: Selecciona la cuenta de la plataforma que quieres seguir + instructions: Kraken importa una única cuenta combinada de plataforma de criptomonedas para esta conexión, con solo posiciones y ejecuciones de operaciones al contado. + no_accounts: Se han importado todas las cuentas de Kraken. + accounts_count: + one: "%{count} cuenta disponible" + other: "%{count} cuentas disponibles" + select_all: Seleccionar todas + import_selected: Importar seleccionadas + cancel: Cancelar + creating: Importando... + complete_account_setup: + success: + one: "Se ha importado %{count} cuenta" + other: "Se han importado %{count} cuentas" + none_selected: No se ha seleccionado ninguna cuenta + no_accounts: No hay cuentas para importar + kraken_item: + provider_name: Kraken + syncing: Sincronizando... + reconnect: Es necesario actualizar las credenciales + deletion_in_progress: Eliminando... + sync_status: + no_accounts: No se han encontrado cuentas + all_synced: + one: "%{count} cuenta sincronizada" + other: "%{count} cuentas sincronizadas" + partial_sync: "%{linked_count} sincronizadas, %{unlinked_count} necesitan configuración" + status: "Sincronizado hace %{timestamp}" + status_with_summary: "Sincronizado hace %{timestamp} - %{summary}" + status_never: Nunca sincronizado + delete: Eliminar + no_accounts_title: No se han encontrado cuentas + no_accounts_message: Tu cuenta de la plataforma Kraken aparecerá aquí después de la sincronización. + setup_needed: Cuenta lista para importar + setup_description: Importa esta conexión de Kraken como una cuenta de plataforma de criptomonedas. + setup_action: Importar cuenta + import_accounts_menu: Importar cuenta + stale_rate_warning: "El saldo es aproximado porque el tipo de cambio exacto para %{date} no estaba disponible. Se actualizará en la próxima sincronización." + kraken_item: + syncer: + checking_credentials: Comprobando credenciales... + credentials_invalid: Credenciales de la API de Kraken no válidas. Comprueba tu clave de API y tu secreto. + importing_accounts: Importando cuentas desde Kraken... + checking_configuration: Comprobando la configuración de la cuenta... + accounts_need_setup: + one: "%{count} cuenta necesita configuración" + other: "%{count} cuentas necesitan configuración" + processing_accounts: Procesando los datos de la cuenta... + calculating_balances: Calculando saldos... diff --git a/config/locales/views/messages/es.yml b/config/locales/views/messages/es.yml new file mode 100644 index 000000000..b5d1a4924 --- /dev/null +++ b/config/locales/views/messages/es.yml @@ -0,0 +1,6 @@ +--- +es: + messages: + chat_form: + placeholder: "Pregunta lo que quieras ..." + disclaimer: "Las respuestas de la IA son solo informativas. ¡No constituyen asesoramiento financiero!" diff --git a/config/locales/views/pending_duplicate_merges/es.yml b/config/locales/views/pending_duplicate_merges/es.yml new file mode 100644 index 000000000..d85917824 --- /dev/null +++ b/config/locales/views/pending_duplicate_merges/es.yml @@ -0,0 +1,21 @@ +--- +es: + pending_duplicate_merges: + create: + no_posted_selected: Selecciona una transacción contabilizada con la que fusionar + invalid_transaction: Transacción seleccionada para fusionar no válida + merge_success: Transacción pendiente fusionada con la transacción contabilizada + merge_failed: No se pudieron fusionar las transacciones + set_transaction: + pending_only: Esta función solo está disponible para transacciones pendientes + new: + title: Fusionar con transacción contabilizada + warning_title: Fusión manual de duplicados + warning_description: Usa esto para fusionar manualmente una transacción pendiente con su versión contabilizada. Se eliminará la transacción pendiente y se conservará únicamente la contabilizada. + pending_transaction: Transacción pendiente + select_posted: Selecciona la transacción contabilizada con la que fusionar + showing_range: "Mostrando %{start} - %{end}" + previous: "← 10 anteriores" + next: "10 siguientes →" + no_candidates: No se han encontrado transacciones contabilizadas en esta cuenta. + submit_button: Fusionar transacciones diff --git a/config/locales/views/sophtron_items/es.yml b/config/locales/views/sophtron_items/es.yml new file mode 100644 index 000000000..f1567857d --- /dev/null +++ b/config/locales/views/sophtron_items/es.yml @@ -0,0 +1,313 @@ +--- +es: + sophtron_items: + defaults: + name: Conexión de Sophtron + new: + title: Conectar Sophtron + user_id: ID de usuario + user_id_placeholder: pega tu ID de usuario de Sophtron + access_key: Clave de acceso + access_key_placeholder: pega tu clave de acceso de Sophtron + connect: Conectar + cancel: Cancelar + create: + success: Conexión de Sophtron creada correctamente + destroy: + success: Conexión de Sophtron eliminada + update: + success: ¡Conexión de Sophtron actualizada correctamente! Tus cuentas se están reconectando. + errors: + blank_user_id: Introduce un ID de usuario de Sophtron. + invalid_user_id: ID de usuario no válido. Comprueba que has copiado el ID de usuario completo de Sophtron. + user_id_compromised: El ID de usuario puede estar comprometido, caducado o ya usado. Crea uno nuevo. + blank_access_key: Introduce una clave de acceso de Sophtron. + invalid_access_key: Clave de acceso no válida. Comprueba que has copiado la clave de acceso completa de Sophtron. + access_key_compromised: La clave de acceso puede estar comprometida, caducada o ya usada. Crea una nueva. + update_failed: "No se pudo actualizar la conexión: %{message}" + unexpected: Se ha producido un error inesperado. Inténtalo de nuevo o ponte en contacto con el soporte. + edit: + user_id: + label: "ID de usuario de Sophtron:" + placeholder: "Pega aquí tu ID de usuario de Sophtron..." + help_text: "El ID de usuario debe ser una cadena larga que empieza por letras y números" + access_key: + label: "Clave de acceso de Sophtron:" + placeholder: "Pega aquí tu clave de acceso de Sophtron..." + help_text: "La clave de acceso debe ser una cadena larga que empieza por letras y números" + index: + title: Conexiones de Sophtron + loading: + loading_message: Cargando cuentas de Sophtron... + loading_title: Cargando + link_accounts: + all_already_linked: + one: "La cuenta seleccionada (%{names}) ya está vinculada" + other: "Las %{count} cuentas seleccionadas ya están vinculadas: %{names}" + api_error: "Error de conexión con la API" + invalid_account_names: + one: "No se puede vincular una cuenta con el nombre en blanco" + other: "No se pueden vincular %{count} cuentas con los nombres en blanco" + link_failed: No se pudieron vincular las cuentas + no_accounts_selected: Selecciona al menos una cuenta + partial_invalid: "Se vincularon %{created_count} cuenta(s) correctamente, %{already_linked_count} ya estaban vinculadas, %{invalid_count} cuenta(s) tenían nombres no válidos" + partial_success: "Se vincularon %{created_count} cuenta(s) correctamente. %{already_linked_count} cuenta(s) ya estaban vinculadas: %{already_linked_names}" + success: + one: "Se ha vinculado %{count} cuenta correctamente" + other: "Se han vinculado %{count} cuentas correctamente" + no_credentials_configured: "Configura primero tu ID de usuario y tu clave de acceso de la API de Sophtron en los Ajustes del proveedor." + no_accounts_found: No se han encontrado cuentas. Comprueba la configuración de tu clave de API. + no_access_key: La clave de acceso de Sophtron no está configurada. Configúrala en los Ajustes. + no_user_id: El ID de usuario de Sophtron no está configurado. Configúralo en los Ajustes. + no_institution_connected: Conecta primero una entidad bancaria con Sophtron. + connect: + cancel: Cancelar + captcha: Captcha + connect: Conectar + institution_search_label: Entidad + institution_search_placeholder: Buscar por nombre del banco + no_institutions: No se han encontrado entidades coincidentes. + password: Contraseña + search: Buscar + search_too_short: Introduce al menos dos caracteres para buscar. + title: Conectar entidad de Sophtron + username: Nombre de usuario + connect_institution: + api_error: "La conexión con Sophtron ha fallado: %{message}" + missing_parameters: Selecciona una entidad e introduce las credenciales de acceso de tu banco. + connection_status: + api_error: "Error de conexión con la API: %{message}" + attempt: "Intento %{attempt} de %{max}" + check_again: Volver a comprobar + failed: Sophtron no pudo completar esta conexión con la entidad. + failed_timeout: Sophtron agotó el tiempo de espera mientras la entidad completaba el acceso. + timeout: Sophtron no terminó de conectarse en el tiempo previsto. Puedes volver a comprobarlo o intentar reconectar más tarde. + title: Conectando con Sophtron + waiting: Sophtron todavía se está conectando con tu entidad. + mfa: + captcha: Texto del captcha + captcha_alt: Captcha de Sophtron + phone_confirmed: He confirmado por teléfono + submit: Enviar + title: Verificación de Sophtron + token: Código de verificación + submit_mfa: + api_error: "La verificación ha fallado: %{message}" + invalid_security_answers: Faltan las respuestas de seguridad o son demasiado largas. + unknown_challenge: Paso de verificación de Sophtron desconocido. + sophtron_item: + accounts_need_setup: Las cuentas necesitan configuración + automatic_sync: Usar sincronización automática + delete: Eliminar conexión + deletion_in_progress: eliminación en curso... + error: Error + no_accounts_description: Esta conexión aún no tiene cuentas vinculadas. + no_accounts_title: Sin cuentas + manual_sync: Sincronización manual + manual_sync_action: Requerir sincronización manual + manual_sync_action_for: "Requerir sincronización manual para %{institution}" + automatic_sync_for: "Usar sincronización automática para %{institution}" + setup_action: Configurar nuevas cuentas + setup_description: "%{linked} de %{total} cuentas vinculadas. Elige los tipos de cuenta para tus cuentas de Sophtron recién importadas." + setup_needed: Nuevas cuentas listas para configurar + status: "Sincronizado hace %{timestamp}" + status_never: Nunca sincronizado + status_with_summary: "Sincronizado hace %{timestamp} • %{summary}" + sync_now: Sincronizar ahora + syncing: Sincronizando... + total: Total + unlinked: Sin vincular + preload_accounts: + preload_accounts: precargar cuentas + api_error: "Error de conexión con la API" + unexpected_error: "Se ha producido un error inesperado" + no_credentials_configured: "Configura primero tu ID de usuario y tu clave de acceso de la API de Sophtron en los Ajustes del proveedor." + no_accounts_found: No se han encontrado cuentas. Comprueba la configuración de tu clave de API. + no_access_key: La clave de acceso de Sophtron no está configurada. Configúrala en los Ajustes. + no_user_id: El ID de usuario de Sophtron no está configurado. Configúralo en los Ajustes. + select_accounts: + accounts_selected: cuentas seleccionadas + api_error: "Error de conexión con la API" + unexpected_error: "Se ha producido un error inesperado" + cancel: Cancelar + configure_name_in_sophtron: "No se puede importar: configura el nombre de la cuenta en Sophtron" + description: Selecciona las cuentas que quieres vincular a tu cuenta de %{product_name}. + link_accounts: Vincular cuentas seleccionadas + no_accounts_found: No se han encontrado cuentas. Comprueba la configuración de tu clave de API. + no_access_key: La clave de acceso de Sophtron no está configurada. Configúrala en los Ajustes. + no_user_id: El ID de usuario de Sophtron no está configurado. Configúralo en los Ajustes. + no_credentials_configured: "Configura primero tu ID de usuario y tu clave de acceso de la API de Sophtron en los Ajustes del proveedor." + no_institution_connected: Conecta primero una entidad bancaria con Sophtron. + no_name_placeholder: "(Sin nombre)" + title: Seleccionar cuentas de Sophtron + select_existing_account: + account_already_linked: Esta cuenta ya está vinculada a un proveedor + all_accounts_already_linked: Todas las cuentas de Sophtron ya están vinculadas + api_error: "Error de conexión con la API" + cancel: Cancelar + configure_name_in_sophtron: "No se puede importar: configura el nombre de la cuenta en Sophtron" + description: Selecciona una cuenta de Sophtron para vincularla a esta cuenta. Las transacciones se sincronizarán y se eliminarán los duplicados automáticamente. + link_account: Vincular cuenta + no_account_specified: No se ha especificado ninguna cuenta + no_accounts_found: No se han encontrado cuentas de Sophtron. Comprueba la configuración de tu clave de API. + no_access_key: La clave de acceso de Sophtron no está configurada. Configúrala en los Ajustes. + no_user_id: El ID de usuario de Sophtron no está configurado. Configúralo en los Ajustes. + no_institution_connected: Conecta primero una entidad bancaria con Sophtron. + no_name_placeholder: "(Sin nombre)" + title: "Vincular %{account_name} con Sophtron" + unexpected_error: "Se ha producido un error inesperado" + link_existing_account: + account_already_linked: Esta cuenta ya está vinculada a un proveedor + api_error: "Error de conexión con la API" + unexpected_error: "Se ha producido un error inesperado" + invalid_account_name: No se puede vincular una cuenta con el nombre en blanco + sophtron_account_already_linked: Esta cuenta de Sophtron ya está vinculada a otra cuenta + sophtron_account_not_found: No se ha encontrado la cuenta de Sophtron + missing_parameters: Faltan parámetros obligatorios + no_institution_connected: Conecta primero una entidad bancaria con Sophtron. + success: "%{account_name} vinculada correctamente con Sophtron" + setup_accounts: + account_type_label: "Tipo de cuenta:" + all_accounts_linked: "Todas tus cuentas de Sophtron ya se han configurado." + api_error: "Error de conexión con la API" + unexpected_error: "Se ha producido un error inesperado" + fetch_failed: "No se pudieron obtener las cuentas" + no_accounts_to_setup: "No hay cuentas para configurar" + no_access_key: "La clave de acceso de Sophtron no está configurada. Comprueba los ajustes de tu conexión." + no_user_id: "El ID de usuario de Sophtron no está configurado. Comprueba los ajustes de tu conexión." + no_institution_connected: "La entidad de Sophtron aún no está conectada." + account_types: + skip: Omitir esta cuenta + depository: Cuenta corriente o de ahorros + credit_card: Tarjeta de crédito + investment: Cuenta de inversión + loan: Préstamo o hipoteca + other_asset: Otro activo + subtype_labels: + depository: "Subtipo de cuenta:" + credit_card: "" + investment: "Tipo de inversión:" + loan: "Tipo de préstamo:" + other_asset: "" + subtype_messages: + credit_card: "Las tarjetas de crédito se configurarán automáticamente como cuentas de tarjeta de crédito." + other_asset: "No se necesitan opciones adicionales para Otros activos." + balance: Saldo + cancel: Cancelar + choose_account_type: "Elige el tipo de cuenta correcto para cada cuenta de Sophtron:" + create_accounts: Crear cuentas + creating_accounts: Creando cuentas... + historical_data_range: "Rango de datos históricos:" + subtitle: Elige los tipos de cuenta correctos para tus cuentas importadas + sync_start_date_help: Selecciona hasta qué fecha quieres sincronizar el historial de transacciones. Hay un máximo de 3 años de historial disponible. + sync_start_date_label: "Empezar a sincronizar transacciones desde:" + title: Configura tus cuentas de Sophtron + complete_account_setup: + all_skipped: "Se omitieron todas las cuentas. No se ha creado ninguna cuenta." + creation_failed: "No se pudieron crear las cuentas" + api_error: "Error de conexión con la API" + unexpected_error: "Se ha producido un error inesperado" + no_accounts: "No hay cuentas para configurar." + success: "Se han creado %{count} cuenta(s) correctamente." + sync: + already_running: Ya hay una sincronización manual de Sophtron en curso. + api_error: "La sincronización manual de Sophtron ha fallado: %{message}" + failed: La sincronización manual de Sophtron ha fallado + no_linked_accounts: Esta entidad de Sophtron no tiene cuentas vinculadas para sincronizar. + processing_failed: La sincronización manual de Sophtron no pudo procesar las transacciones actualizadas. + success: Sincronización iniciada + toggle_manual_sync: + success_disabled: La entidad de Sophtron se sincronizará automáticamente. + success_enabled: La entidad de Sophtron ahora requiere sincronización manual. + manual_sync_complete: + close: Cerrar + description: Los saldos de las cuentas terminarán de actualizarse en segundo plano. + message: Las transacciones se descargaron tras la verificación de Sophtron. + title: Sincronización de Sophtron iniciada + sophtron_setup_required: + title: Configuración de Sophtron necesaria + message: > + Para completar la configuración de tu conexión de Sophtron, ve a la página de Ajustes del proveedor y sigue las instrucciones para autorizar y configurar tu conexión de Sophtron. + go_to_provider_settings: Ir a los Ajustes del proveedor + heading: "ID de usuario y clave de acceso no configurados" + description: "Antes de poder vincular cuentas de Sophtron, debes configurar tu ID de usuario y tu clave de acceso de Sophtron." + setup_steps_title: "Pasos de configuración:" + step_1_html: "Ve a Ajustes → Proveedores de sincronización bancaria" + step_2_html: "Busca la sección Sophtron" + step_3_html: "Introduce tu ID de usuario y tu clave de acceso de Sophtron" + step_4: "Vuelve aquí para vincular tus cuentas" + api_error: + title: "Error de conexión con Sophtron" + unable_to_connect: "No se puede conectar con Sophtron" + institution_unable_to_connect: "No se puede conectar con la entidad" + common_issues_title: "Problemas habituales:" + incorrect_user_id: "ID de usuario incorrecto: Verifica tu ID de usuario en los Ajustes del proveedor" + invalid_access_key: "Clave de acceso no válida: Comprueba tu clave de acceso en los Ajustes del proveedor" + expired_credentials: "Credenciales caducadas: Genera un nuevo ID de usuario y una nueva clave de acceso en Sophtron" + network_issue: "Problema de red: Comprueba tu conexión a internet" + service_down: "Servicio no disponible: La API de Sophtron puede estar temporalmente no disponible" + bad_credentials: "Credenciales bancarias: Comprueba que el nombre de usuario y la contraseña sean correctos" + verification_code: "Código de verificación: Asegúrate de introducir el código más reciente antes de que caduque" + institution_timeout: "Tiempo de espera de la entidad agotado: La página de acceso del banco no terminó a tiempo" + unsupported_mfa: "Compatibilidad con MFA: Es posible que Sophtron no admita el flujo de verificación actual de esta entidad" + check_provider_settings: "Comprobar los Ajustes del proveedor" + try_again: "Volver a intentar la conexión" + select_option: "Seleccionar %{type}" + subtype: "subtipo" + type: "tipo" + sophtron_panel: + setup_instructions_title: "Instrucciones de configuración:" + setup_instructions: + step_1_html: 'Visita Sophtron para obtener tus credenciales de API' + step_2: "Copia tu ID de usuario y tu clave de acceso desde los ajustes de tu cuenta de Sophtron" + step_3: "Pega las credenciales abajo y haz clic en Guardar; Sure creará o reutilizará tu ID de cliente de Sophtron automáticamente" + field_descriptions_title: "Descripciones de los campos:" + field_descriptions: + user_id_html: "ID de usuario: Tu credencial de ID de usuario de Sophtron" + access_key_html: "Clave de acceso: Tu credencial de clave de acceso de Sophtron" + base_url_html: "URL base: La URL del endpoint de la API de Sophtron, normalmente https://api.sophtron.com/api" + fields: + user_id: + label: "ID de usuario" + placeholder_new: "Pega tu ID de usuario de Sophtron" + placeholder_edit: "••••••••" + access_key: + label: "Clave de acceso" + placeholder_new: "Pega tu clave de acceso de Sophtron" + placeholder_edit: "••••••••" + base_url: + label: "URL base" + placeholder: "https://api.sophtron.com/api" + save: "Guardar configuración" + update: "Actualizar configuración" + syncer: + manual_sync_required: "Para esta entidad se requiere una sincronización manual de Sophtron; esas cuentas se omiten durante la sincronización automática." + importing_accounts: "Importando cuentas desde Sophtron..." + checking_account_configuration: "Comprobando la configuración de la cuenta..." + accounts_need_setup: "%{count} cuenta(s) necesitan configuración" + processing_transactions: "Procesando transacciones de las cuentas vinculadas..." + calculating_balances: "Calculando saldos de las cuentas vinculadas..." + sophtron_entry: + processor: + unknown_transaction: "Transacción desconocida" + render_connection_timeout: + timeout: "Se ha agotado el tiempo de conexión. Inténtalo de nuevo." + redirect_after_account_link: + invalid_account_names: + one: "No se puede vincular %{count} cuenta con el nombre en blanco" + other: "No se pueden vincular %{count} cuentas con los nombres en blanco" + partial_invalid: "Se vincularon %{created_count} cuenta(s). %{already_linked_count} ya estaban vinculadas, %{invalid_count} tenían nombres no válidos." + partial_success: "Se vincularon %{created_count} cuenta(s). %{already_linked_count} cuenta(s) ya estaban vinculadas." + success: + one: "Se ha vinculado %{count} cuenta correctamente." + other: "Se han vinculado %{count} cuentas correctamente." + all_already_linked: + one: "La cuenta seleccionada ya está vinculada" + other: "Las %{count} cuentas seleccionadas ya están vinculadas" + link_failed: "No se pudieron vincular las cuentas" + start_manual_sync: + already_running: "Ya hay una sincronización en curso." + no_linked_accounts: "No hay cuentas vinculadas disponibles para sincronizar." + api_error: "Error de la API: %{message}" + start_manual_sync_for_account: + failed: "No se pudo sincronizar la cuenta" diff --git a/config/locales/views/splits/es.yml b/config/locales/views/splits/es.yml new file mode 100644 index 000000000..dc378a8c5 --- /dev/null +++ b/config/locales/views/splits/es.yml @@ -0,0 +1,47 @@ +--- +es: + splits: + new: + title: Dividir transacción + description: Divide esta transacción en varias entradas con categorías e importes diferentes. + submit: Dividir transacción + cancel: Cancelar + add_row: Añadir división + remove_row: Eliminar + remaining: Restante + amounts_must_match: Los importes de las divisiones deben sumar el importe original de la transacción. + name_label: Nombre + name_placeholder: Nombre de la división + amount_label: Importe + category_label: Categoría + uncategorized: "(sin categoría)" + original_name: "Nombre:" + original_date: "Fecha:" + original_amount: "Importe" + split_number: "División n.º %{number}" + create: + success: Transacción dividida correctamente + not_splittable: Esta transacción no se puede dividir. + destroy: + success: División de la transacción deshecha correctamente + show: + title: Entradas divididas + description: Esta transacción se ha dividido en las siguientes entradas. + button_title: Dividir transacción + button_description: Divide esta transacción en varias entradas con categorías e importes diferentes. + button: Dividir + unsplit_title: Deshacer división + unsplit_button: Deshacer división + unsplit_confirm: Esto eliminará todas las entradas divididas y restaurará la transacción original. + edit: + title: Editar división + description: Modifica las entradas divididas de esta transacción. + submit: Actualizar división + not_split: Esta transacción no está dividida. + update: + success: División actualizada correctamente + child: + title: Parte de una división + description: Esta entrada forma parte de una transacción dividida. + edit_split: Editar división + unsplit: Deshacer división From a83619eda5fa29a83c6a8196b00915b03f719d46 Mon Sep 17 00:00:00 2001 From: glorydavid03023 Date: Wed, 3 Jun 2026 04:57:09 -0500 Subject: [PATCH 04/20] feat(i18n): complete German (de) locale coverage for provider & transaction views (#2158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several view namespaces only shipped a subset of the 14-locale baseline (ca, de, en, es, fr, hu, nb, nl, pl, pt-BR, ro, tr, zh-CN, zh-TW), leaving German users on English fallbacks for these screens. This adds German translations for every namespace that had an English source but was missing `de`, bringing them to baseline parity: - account_sharings, account_statements, splits, transfer_matches, pending_duplicate_merges, securities, messages - Provider connections: binance_items, brex_items, ibkr_items, kraken_items, sophtron_items All files mirror the en.yml key structure exactly (865 keys, verified with key-parity and %{interpolation} placeholder checks) and reuse existing German terminology already established in the repo (Konto, synchronisiert, Zugangsdaten, Verbindung, verknüpfen, Saldo; formal "Sie"). Also fixes a typo'd filename: config/locales/doorkepper.de.yml -> doorkeeper.de.yml (German Doorkeeper strings). Co-authored-by: Claude Opus 4.8 (1M context) --- .../{doorkepper.de.yml => doorkeeper.de.yml} | 0 config/locales/views/account_sharings/de.yml | 29 ++ .../locales/views/account_statements/de.yml | 116 +++++++ config/locales/views/binance_items/de.yml | 75 +++++ config/locales/views/brex_items/de.yml | 277 ++++++++++++++++ config/locales/views/ibkr_items/de.yml | 92 +++++ config/locales/views/kraken_items/de.yml | 85 +++++ config/locales/views/messages/de.yml | 6 + .../views/pending_duplicate_merges/de.yml | 21 ++ config/locales/views/securities/de.yml | 14 + config/locales/views/sophtron_items/de.yml | 313 ++++++++++++++++++ config/locales/views/splits/de.yml | 47 +++ config/locales/views/transfer_matches/de.yml | 24 ++ 13 files changed, 1099 insertions(+) rename config/locales/{doorkepper.de.yml => doorkeeper.de.yml} (100%) create mode 100644 config/locales/views/account_sharings/de.yml create mode 100644 config/locales/views/account_statements/de.yml create mode 100644 config/locales/views/binance_items/de.yml create mode 100644 config/locales/views/brex_items/de.yml create mode 100644 config/locales/views/ibkr_items/de.yml create mode 100644 config/locales/views/kraken_items/de.yml create mode 100644 config/locales/views/messages/de.yml create mode 100644 config/locales/views/pending_duplicate_merges/de.yml create mode 100644 config/locales/views/securities/de.yml create mode 100644 config/locales/views/sophtron_items/de.yml create mode 100644 config/locales/views/splits/de.yml create mode 100644 config/locales/views/transfer_matches/de.yml diff --git a/config/locales/doorkepper.de.yml b/config/locales/doorkeeper.de.yml similarity index 100% rename from config/locales/doorkepper.de.yml rename to config/locales/doorkeeper.de.yml diff --git a/config/locales/views/account_sharings/de.yml b/config/locales/views/account_sharings/de.yml new file mode 100644 index 000000000..f770ce305 --- /dev/null +++ b/config/locales/views/account_sharings/de.yml @@ -0,0 +1,29 @@ +--- +de: + account_sharings: + show: + title: Kontofreigabe + subtitle: Legen Sie fest, wer dieses Konto sehen und damit interagieren kann + member: Mitglied + permission: Berechtigung + shared: Freigegeben + no_members: Keine weiteren Mitglieder in Ihrem %{moniker} zum Teilen + permissions: + full_control: Volle Kontrolle + full_control_description: Kann Transaktionen ansehen, bearbeiten und verwalten + read_write: Kann kommentieren + read_write_description: Kann kategorisieren, mit Tags versehen und Notizen hinzufügen + read_only: Nur ansehen + read_only_description: Kann nur Kontodaten ansehen + save: Freigabeeinstellungen speichern + owner_label: "Eigentümer: %{name}" + shared_with_count: + one: Mit 1 Mitglied geteilt + other: "Mit %{count} Mitgliedern geteilt" + include_in_finances: In meine Budgets & Berichte einbeziehen + exclude_from_finances: Von meinen Budgets & Berichten ausschließen + finance_toggle_description: Dieses Konto in Ihrem Nettovermögen, Budgets und Berichten berücksichtigen + update: + success: Freigabeeinstellungen aktualisiert + not_owner: Nur der Kontoeigentümer kann die Freigabe verwalten + finance_toggle_success: Einstellung zur Finanzberücksichtigung aktualisiert diff --git a/config/locales/views/account_statements/de.yml b/config/locales/views/account_statements/de.yml new file mode 100644 index 000000000..52d629ca4 --- /dev/null +++ b/config/locales/views/account_statements/de.yml @@ -0,0 +1,116 @@ +--- +de: + account_statements: + account_tab: + coverage_title: Kontoauszug-Abdeckung + coverage_description: Historische Monate, die durch hochgeladene Kontoauszüge und Saldoprüfungen belegt sind. + coverage_range: "%{start} - %{end}" + empty: Diesem Konto sind noch keine Kontoauszüge zugeordnet. + open_inbox: Posteingang + statements_title: Kontoauszüge + year_label: Abdeckungsjahr + balance: + unknown: Unbekannt + coverage: + status: + ambiguous: Mehrdeutig + covered: Abgedeckt + duplicate: Duplikat + mismatched: Abweichend + missing: Fehlend + not_expected: Nicht erwartet + create: + duplicates: + one: 1 doppelter Kontoauszug wurde übersprungen. + other: "%{count} doppelte Kontoauszüge wurden übersprungen." + invalid_file_type: Laden Sie einen Kontoauszug als PDF, CSV oder XLSX innerhalb der Größenbeschränkung hoch. + no_files: Wählen Sie mindestens eine Kontoauszugsdatei aus. + success: + one: 1 Kontoauszug hochgeladen. + other: "%{count} Kontoauszüge hochgeladen." + destroy: + failure: Kontoauszug konnte nicht gelöscht werden. + success: Kontoauszug gelöscht. + form: + account_upload: Kontoauszug hochladen + files_hint: PDF, CSV oder XLSX. Max. %{max_size} MB pro Datei. + files_label: Kontoauszugsdateien + inbox_upload: Hochladen + index: + account_label: Konto + confidence: "%{confidence} Übereinstimmung" + empty_linked: Noch keine verknüpften Kontoauszüge. + empty_unmatched: Der Kontoauszug-Posteingang ist leer. + leave_unmatched: Nicht zugeordnet lassen + linked_title: Verknüpfte Kontoauszüge + no_suggestion: Kein Vorschlag + storage_used: Belegter Speicher + title: Kontoauszug-Tresor + unmatched_title: Nicht zugeordneter Posteingang + upload_description: Laden Sie Kontoauszüge in den Posteingang hoch oder wählen Sie ein Konto zur sofortigen Verknüpfung. + upload_title: Kontoauszüge hochladen + link: + no_account: Wählen Sie ein Konto aus, bevor Sie diesen Kontoauszug verknüpfen. + success: Kontoauszug mit %{account} verknüpft. + period: + unknown: Zeitraum unbekannt + reconciliation: + checks: + closing_balance: Schlusssaldo + opening_balance: Anfangssaldo + period_movement: Bewegung im Zeitraum + unknown_check: Unbekannte Prüfung + matched: Übereinstimmend + mismatched: Abweichend + unavailable: Nicht geprüft + reject: + success: Kontoauszug-Zuordnung abgelehnt. + show: + account_label: Konto + account_last4_hint: Letzte vier Ziffern des Kontos + account_name_hint: Hinweis zum Kontonamen + closing_balance: Schlusssaldo + currency: Währung + delete: Löschen + difference: Differenz + download: Herunterladen + institution_name_hint: Hinweis zum Institut + ledger_amount: Sure-Buchung + linked_to: Verknüpft mit %{account}. + linking_title: Kontoverknüpfung + link_suggestion: Verknüpfungsvorschlag + metadata_title: Kontoauszug-Metadaten + no_suggestion: Noch kein Kontovorschlag. + opening_balance: Anfangssaldo + period_end_on: Ende des Zeitraums + period_start_on: Beginn des Zeitraums + reconciliation_title: Abgleich + reconciliation_unavailable: Fügen Sie einen Kontoauszugszeitraum und einen Anfangs- oder Schlusssaldo hinzu und stellen Sie sicher, dass Sure für diese Daten einen Saldoverlauf hat. + reject: Ablehnen + save: Kontoauszug speichern + statement_amount: Kontoauszug + suggested_account: Vorgeschlagenes Konto ist %{account} (%{confidence} Konfidenz). + title: Kontoauszug + unlink: Verknüpfung aufheben + unmatched_account: Nicht zugeordneter Posteingang + unknown_value: Unbekannt + status: + linked: Verknüpft + rejected: Abgelehnt + unmatched: Nicht zugeordnet + table: + account: Konto + actions: Aktionen + download: Herunterladen + file: Datei + link_suggestion: Verknüpfungsvorschlag + period: Zeitraum + reconciliation: Abgleich + reject: Vorschlag ablehnen + suggestion: Vorschlag + unlink: Verknüpfung aufheben + view: Ansehen + unlink: + success: Kontoauszug zurück in den nicht zugeordneten Posteingang verschoben. + update: + success: Kontoauszug aktualisiert. diff --git a/config/locales/views/binance_items/de.yml b/config/locales/views/binance_items/de.yml new file mode 100644 index 000000000..52c731826 --- /dev/null +++ b/config/locales/views/binance_items/de.yml @@ -0,0 +1,75 @@ +--- +de: + binance_items: + create: + default_name: Binance + success: Erfolgreich mit Binance verbunden! Ihr Konto wird synchronisiert. + update: + success: Binance-Konfiguration wurde erfolgreich aktualisiert. + destroy: + success: Binance-Verbindung wurde zur Löschung vorgemerkt. + setup_accounts: + title: Binance-Konto importieren + subtitle: Wählen Sie die zu verfolgenden Portfolios + instructions: Wählen Sie die Binance-Portfolios aus, die Sie importieren möchten. Es werden nur Portfolios mit Salden angezeigt. + no_accounts: Alle Konten wurden bereits importiert. + accounts_count: + one: "%{count} Konto verfügbar" + other: "%{count} Konten verfügbar" + select_all: Alle auswählen + import_selected: Ausgewählte importieren + cancel: Abbrechen + creating: Importiere... + complete_account_setup: + success: + one: "%{count} Konto importiert" + other: "%{count} Konten importiert" + none_selected: Keine Konten ausgewählt + no_accounts: Keine Konten zum Import + binance_item: + provider_name: Binance + syncing: Synchronisiere... + reconnect: Zugangsdaten müssen aktualisiert werden + deletion_in_progress: Wird gelöscht... + sync_status: + no_accounts: Keine Konten gefunden + all_synced: + one: "%{count} Konto synchronisiert" + other: "%{count} Konten synchronisiert" + partial_sync: "%{linked_count} synchronisiert, %{unlinked_count} müssen eingerichtet werden" + status: "Zuletzt synchronisiert vor %{timestamp}" + status_with_summary: "Zuletzt synchronisiert vor %{timestamp} – %{summary}" + status_never: Noch nie synchronisiert + update_credentials: Zugangsdaten aktualisieren + delete: Löschen + no_accounts_title: Keine Konten gefunden + no_accounts_message: Ihr Binance-Portfolio erscheint hier nach der Synchronisierung. + setup_needed: Konto bereit zum Import + setup_description: Wählen Sie die Binance-Portfolios, die Sie verfolgen möchten. + setup_action: Konto importieren + import_accounts_menu: Konto importieren + stale_rate_warning: "Der Saldo ist ein Näherungswert – der genaue Wechselkurs für %{date} war nicht verfügbar. Er wird bei der nächsten Synchronisierung aktualisiert." + select_existing_account: + title: Binance-Konto verknüpfen + no_accounts_found: Keine Binance-Konten gefunden. + wait_for_sync: Warten Sie, bis Binance die Synchronisierung abgeschlossen hat + check_provider_health: Prüfen Sie, ob Ihre Binance-API-Zugangsdaten gültig sind + currently_linked_to: "Aktuell verknüpft mit: %{account_name}" + link: Verknüpfen + cancel: Abbrechen + link_existing_account: + success: Erfolgreich mit Binance-Konto verknüpft + errors: + only_manual: Nur manuelle Konten können mit Binance verknüpft werden + invalid_binance_account: Ungültiges Binance-Konto + binance_item: + syncer: + checking_credentials: Zugangsdaten werden geprüft... + credentials_invalid: Ungültige API-Zugangsdaten. Bitte überprüfen Sie Ihren API-Schlüssel und Ihr Secret. + importing_accounts: Konten werden von Binance importiert... + checking_configuration: Kontokonfiguration wird geprüft... + accounts_need_setup: + one: "%{count} Konto muss eingerichtet werden" + other: "%{count} Konten müssen eingerichtet werden" + processing_accounts: Kontodaten werden verarbeitet... + calculating_balances: Salden werden berechnet... diff --git a/config/locales/views/brex_items/de.yml b/config/locales/views/brex_items/de.yml new file mode 100644 index 000000000..a724c2cf5 --- /dev/null +++ b/config/locales/views/brex_items/de.yml @@ -0,0 +1,277 @@ +--- +de: + brex_items: + default_connection_name: Brex-Verbindung + account_metadata: + provider: Brex + separator: " • " + kinds: + cash: Bargeld + card: Karte + statuses: + ACTIVE: Aktiv + active: Aktiv + CLOSED: Geschlossen + closed: Geschlossen + frozen: Eingefroren + FROZEN: Eingefroren + create: + success: Brex-Verbindung erfolgreich erstellt + default_card_name: Brex-Karte + default_cash_name: "Brex Cash %{id}" + destroy: + success: Brex-Verbindung entfernt + index: + title: Brex-Verbindungen + institution_summary: + none: Keine Institute verbunden + one: "%{name}" + count: + one: "%{count} Institut" + other: "%{count} Institute" + sync_status: + no_accounts: Keine Konten gefunden + all_synced: + one: "%{count} Konto synchronisiert" + other: "%{count} Konten synchronisiert" + partial_setup: "%{synced} synchronisiert, %{pending} müssen eingerichtet werden" + api_error: + common_issues: "Häufige Probleme:" + expired_credentials: Erstellen Sie ein neues API-Token bei Brex. + expired_credentials_label: "Abgelaufene Zugangsdaten:" + heading: Verbindung zu Brex nicht möglich + invalid_token: Überprüfen Sie Ihr API-Token in den Anbietereinstellungen. + invalid_token_label: "Ungültiges API-Token:" + network: Überprüfen Sie Ihre Internetverbindung. + network_label: "Netzwerkproblem:" + permissions: Stellen Sie sicher, dass Ihr Token über die erforderlichen schreibgeschützten Konto- und Transaktions-Scopes verfügt. + permissions_label: "Unzureichende Berechtigungen:" + service: Die Brex-API ist möglicherweise vorübergehend nicht verfügbar. + service_label: "Dienst nicht verfügbar:" + settings_link: Anbietereinstellungen prüfen + title: Brex-Verbindungsfehler + errors: + unexpected_error: Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut. + entries: + default_name: Brex-Transaktion + loading: + loading_message: Brex-Konten werden geladen... + loading_title: Wird geladen + link_accounts: + all_already_linked: + one: "Das ausgewählte Konto (%{names}) ist bereits verknüpft" + other: "Alle %{count} ausgewählten Konten sind bereits verknüpft: %{names}" + api_error: "API-Fehler: %{message}" + invalid_account_names: + one: "Konto mit leerem Namen kann nicht verknüpft werden" + other: "%{count} Konten mit leeren Namen können nicht verknüpft werden" + invalid_account_type: Nicht unterstützter Brex-Kontotyp + link_failed: Konten konnten nicht verknüpft werden + no_accounts_selected: Bitte wählen Sie mindestens ein Konto aus + no_api_token: Brex-API-Token nicht gefunden. Bitte konfigurieren Sie es in den Anbietereinstellungen. + partial_invalid: "%{created_count} Konto(en) erfolgreich verknüpft, %{already_linked_count} Konto(en) waren bereits verknüpft, %{invalid_count} Konto(en) hatten ungültige Namen" + partial_success: "%{created_count} Konto(en) erfolgreich verknüpft. %{already_linked_count} Konto(en) waren bereits verknüpft: %{already_linked_names}" + select_connection: Wählen Sie eine Brex-Verbindung aus, bevor Sie Konten verknüpfen. + success: + one: "%{count} Konto erfolgreich verknüpft" + other: "%{count} Konten erfolgreich verknüpft" + brex_item: + accounts_need_setup: Konten müssen eingerichtet werden + delete: Verbindung löschen + deletion_in_progress: Löschung läuft... + error: Fehler + no_accounts_description: Diese Verbindung hat noch keine verknüpften Konten. + no_accounts_title: Keine Konten + setup_action: Neue Konten einrichten + setup_description: "%{linked} von %{total} Konten verknüpft. Wählen Sie Kontotypen für Ihre neu importierten Brex-Konten." + setup_needed: Neue Konten bereit zur Einrichtung + status: "Vor %{timestamp} synchronisiert" + status_never: Noch nie synchronisiert + status_with_summary: "Zuletzt synchronisiert vor %{timestamp} – %{summary}" + syncing: Synchronisiere... + total: Gesamt + unlinked: Nicht verknüpft + provider_panel: + accounts_link: Konten + add_connection: Brex-Verbindung hinzufügen + base_url_label: Basis-URL (optional) + base_url_placeholder: https://api.brex.com + configured_html: "Konfiguriert und einsatzbereit. Besuchen Sie den Tab %{accounts_link}, um Konten zu verwalten und einzurichten." + connection_name_label: Verbindungsname + connection_name_placeholder: Geschäftskonto + default_connection_name: Brex-Verbindung + disconnect_label: "%{name} trennen" + disconnect_confirm: "%{name} trennen?" + encryption_warning: + title: Datenbankverschlüsselung ist nicht konfiguriert + message: Konfigurieren Sie die Active Record-Verschlüsselungsschlüssel, bevor Sie Brex-Tokens in der Produktion hinzufügen. Ohne Verschlüsselungsschlüssel speichert Sure die Brex-Anbieter-Zugangsdaten und Snapshots im Klartext, wie bei anderen Anbieterdatensätzen. + instructions: + copy_token_html: "Kopieren Sie das Token und fügen Sie es unten als benannte Verbindung hinzu. Sure speichert das Token nur zur Synchronisierung dieser Familie." + create_token: "Erstellen Sie ein API-Token mit diesen schreibgeschützten Scopes: accounts.cash.readonly, accounts.card.readonly, transactions.cash.readonly, transactions.card.readonly" + open_tokens: Gehen Sie zu den Brex-Entwickler-/API-Token-Einstellungen für das Unternehmen, das Sie verbinden möchten + sign_in_html: "Besuchen Sie %{link} und melden Sie sich bei dem Konto an, das Sie verbinden möchten" + keep_token_placeholder: Leer lassen, um das aktuelle Token beizubehalten + not_configured: Nicht konfiguriert + sandbox_note_html: "Verwenden Sie für jedes Brex-Unternehmen/API-Token, das Sie synchronisieren möchten, eine separate benannte Verbindung. Lassen Sie die Basis-URL für die Produktion leer. Staging ist auf von Brex genehmigte Tests beschränkt und funktioniert nicht mit Kunden-Tokens." + setup_accounts: Konten einrichten + setup_title: "Einrichtungsanweisungen:" + sync: Synchronisieren + token_label: Token + token_placeholder: Token hier einfügen + update_connection: Verbindung aktualisieren + provider_connection: + default_description: Mit Ihrem Brex-Konto verbinden + default_name: Brex + description: "Verbinden mit %{name}" + name: "Brex - %{name}" + select_accounts: + accounts_selected: Konten ausgewählt + api_error: "API-Fehler: %{message}" + cancel: Abbrechen + configure_name_in_brex: Import nicht möglich – bitte konfigurieren Sie den Kontonamen in Brex + description: Wählen Sie die Konten aus, die Sie mit Ihrem %{product_name}-Konto verknüpfen möchten. + link_accounts: Ausgewählte Konten verknüpfen + no_accounts_found: Keine Konten gefunden. Bitte überprüfen Sie Ihre API-Token-Konfiguration. + no_api_token: Brex-API-Token nicht gefunden. Bitte konfigurieren Sie es in den Anbietereinstellungen. + no_credentials_configured: Bitte konfigurieren Sie zuerst Ihr Brex-API-Token in den Anbietereinstellungen. + no_name_placeholder: "(Kein Name)" + select_connection: Wählen Sie in den Anbietereinstellungen eine Brex-Verbindung aus. + title: Brex-Konten auswählen + unexpected_error: Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut. + select_existing_account: + account_already_linked: Dieses Konto ist bereits mit einem Anbieter verknüpft + all_accounts_already_linked: Alle Brex-Konten sind bereits verknüpft + api_error: "API-Fehler: %{message}" + cancel: Abbrechen + configure_name_in_brex: Import nicht möglich – bitte konfigurieren Sie den Kontonamen in Brex + description: Wählen Sie ein Brex-Konto aus, das mit diesem Konto verknüpft werden soll. Transaktionen werden automatisch synchronisiert und dedupliziert. + link_account: Konto verknüpfen + no_account_specified: Kein Konto angegeben + no_accounts_found: Keine Brex-Konten gefunden. Bitte überprüfen Sie Ihre API-Token-Konfiguration. + no_api_token: Brex-API-Token nicht gefunden. Bitte konfigurieren Sie es in den Anbietereinstellungen. + no_credentials_configured: Bitte konfigurieren Sie zuerst Ihr Brex-API-Token in den Anbietereinstellungen. + no_name_placeholder: "(Kein Name)" + select_connection: Wählen Sie in den Anbietereinstellungen eine Brex-Verbindung aus. + title: "%{account_name} mit Brex verknüpfen" + unexpected_error: Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut. + setup_required: + description: Bevor Sie Brex-Konten verknüpfen können, müssen Sie Ihr Brex-API-Token konfigurieren. + heading: API-Token nicht konfiguriert + settings_link: Zu den Anbietereinstellungen + setup_steps: "Einrichtungsschritte:" + steps: + enter_token: Geben Sie Ihr Brex-API-Token ein + find_section_html: "Suchen Sie den Bereich Brex" + open_settings_html: "Gehen Sie zu Einstellungen > Anbieter" + return_to_link: Kehren Sie hierher zurück, um Ihre Konten zu verknüpfen + title: Brex-Einrichtung erforderlich + subtype_select: + placeholder: + subtype: Untertyp auswählen + type: Typ auswählen + link_existing_account: + account_already_linked: Dieses Konto ist bereits mit einem Anbieter verknüpft + api_error: "API-Fehler: %{message}" + invalid_account_name: Konto mit leerem Namen kann nicht verknüpft werden + missing_parameters: Erforderliche Parameter fehlen + no_account_specified: Kein Konto angegeben + no_api_token: Brex-API-Token nicht gefunden. Bitte konfigurieren Sie es in den Anbietereinstellungen. + provider_account_already_linked: Dieses Brex-Konto ist bereits mit einem anderen Konto verknüpft + provider_account_not_found: Brex-Konto nicht gefunden + select_connection: Wählen Sie eine Brex-Verbindung aus, bevor Sie Konten verknüpfen. + success: "%{account_name} erfolgreich mit Brex verknüpft" + setup_accounts: + account_type_label: "Kontotyp:" + all_accounts_linked: "Alle Ihre Brex-Konten wurden bereits eingerichtet." + api_error: "API-Fehler: %{message}" + fetch_failed: "Konten konnten nicht abgerufen werden" + no_accounts_to_setup: "Keine Konten zum Einrichten" + no_api_token: Brex-API-Token nicht gefunden. Bitte konfigurieren Sie es in den Anbietereinstellungen. + account_types: + skip: Dieses Konto überspringen + depository: Giro- oder Sparkonto + credit_card: Kreditkarte + investment: Investmentkonto + loan: Darlehen oder Hypothek + other_asset: Sonstiger Vermögenswert + subtype_labels: + depository: "Kontountertyp:" + credit_card: "" + investment: "Investmenttyp:" + loan: "Darlehenstyp:" + other_asset: "" + subtype_messages: + credit_card: "Kreditkarten werden automatisch als Kreditkartenkonten eingerichtet." + other_asset: "Für sonstige Vermögenswerte sind keine zusätzlichen Optionen erforderlich." + subtypes: + depository: + checking: Girokonto + savings: Sparkonto + hsa: Gesundheitssparkonto + cd: Festgeld + money_market: Geldmarktkonto + investment: + brokerage: Brokerage + pension: Rente + retirement: Altersvorsorge + "401k": "401(k)" + roth_401k: "Roth 401(k)" + "403b": "403(b)" + tsp: Thrift Savings Plan + "529_plan": "529-Plan" + hsa: Gesundheitssparkonto + mutual_fund: Investmentfonds + ira: Traditionelle IRA + roth_ira: Roth IRA + angel: Angel + loan: + mortgage: Hypothek + student: Studienkredit + auto: Autokredit + other: Sonstiges Darlehen + balance: Saldo + cancel: Abbrechen + choose_account_type: "Wählen Sie den richtigen Kontotyp für jedes Brex-Konto:" + create_accounts: Konten erstellen + creating_accounts: Konten werden erstellt... + historical_data_range: "Historischer Datenbereich:" + subtitle: Wählen Sie die richtigen Kontotypen für Ihre importierten Konten + sync_start_date_help: Wählen Sie aus, wie weit zurück Sie den Transaktionsverlauf synchronisieren möchten. Maximal 3 Jahre Verlauf verfügbar. + sync_start_date_label: "Transaktionen synchronisieren ab:" + title: Richten Sie Ihre Brex-Konten ein + complete_account_setup: + all_skipped: "Alle Konten wurden übersprungen. Es wurden keine Konten erstellt." + creation_failed: "Konten konnten nicht erstellt werden: %{error}" + creation_failed_count: "%{count} Konto(en) konnten nicht erstellt werden." + no_accounts: "Keine Konten zum Einrichten." + partial_skipped: "%{created_count} Konto(en) erfolgreich erstellt; %{skipped_count} Konto(en) wurden übersprungen." + partial_success: "%{created_count} Konto(en) erfolgreich erstellt, aber %{failed_count} Konto(en) sind fehlgeschlagen." + success: "%{count} Konto(en) erfolgreich erstellt." + unexpected_error: Ein unerwarteter Fehler ist aufgetreten. + sync: + success: Synchronisierung gestartet + syncer: + account_processing_failed: + one: "%{count} Brex-Konto ist bei der Verarbeitung fehlgeschlagen." + other: "%{count} Brex-Konten sind bei der Verarbeitung fehlgeschlagen." + account_sync_failed: + one: "%{count} Brex-Kontosynchronisierung konnte nicht geplant werden." + other: "%{count} Brex-Kontosynchronisierungen konnten nicht geplant werden." + accounts_need_setup: + one: "%{count} Konto muss eingerichtet werden..." + other: "%{count} Konten müssen eingerichtet werden..." + accounts_failed: + one: "%{count} Brex-Konto konnte nicht importiert werden." + other: "%{count} Brex-Konten konnten nicht importiert werden." + calculating_balances: Salden werden berechnet... + checking_account_configuration: Kontokonfiguration wird geprüft... + credentials_invalid: Ungültiges Brex-API-Token oder ungültige Kontoberechtigungen + failed: Synchronisierung fehlgeschlagen. Bitte versuchen Sie es erneut oder wenden Sie sich an den Support. + import_failed: Brex-Import fehlgeschlagen. + importing_accounts: Konten werden von Brex importiert... + processing_transactions: Transaktionen werden verarbeitet... + transactions_failed: + one: "%{count} Brex-Konto hatte Fehler beim Transaktionsimport." + other: "%{count} Brex-Konten hatten Fehler beim Transaktionsimport." + update: + success: Brex-Verbindung aktualisiert diff --git a/config/locales/views/ibkr_items/de.yml b/config/locales/views/ibkr_items/de.yml new file mode 100644 index 000000000..0190a74bc --- /dev/null +++ b/config/locales/views/ibkr_items/de.yml @@ -0,0 +1,92 @@ +--- +de: + providers: + ibkr: + name: Interactive Brokers + connection_description: Verbinden Sie einen Interactive Brokers Flex Web Service-Bericht + institution_name: Interactive Brokers + ibkr_items: + defaults: + name: Interactive Brokers + ibkr_item: + deletion_in_progress: Löschung läuft + flex_web_service: Flex Web Service + syncing: Synchronisiere + requires_update: Zugangsdaten erfordern Aufmerksamkeit + error: Fehler + synced: Vor %{time} synchronisiert. %{summary}. + never_synced: Noch nie synchronisiert. + setup_accounts: Konten einrichten + delete: Löschen + accounts_need_setup: Konten müssen eingerichtet werden + accounts_need_setup_description: Einige Konten von IBKR müssen mit Sure-Konten verknüpft werden. + no_accounts_discovered: Noch keine IBKR-Konten gefunden. + no_accounts_discovered_description: Führen Sie nach der Konfiguration Ihrer Flex-Abfrage eine Synchronisierung durch, um Konten zu finden. + setup_accounts: + page_title: Interactive Brokers-Konten einrichten + dialog_title: Richten Sie Ihre Interactive Brokers-Konten ein + subtitle: Wählen Sie aus, welche IBKR-Brokerage-Konten verknüpft werden sollen. + info_box: + title: IBKR Flex-Abfrage-Import + items: + item_1: Bestände mit aktuellen Kursen und Stückzahlen + item_2: Einstandskosten pro Position + item_3: Trades, Dividenden, Provisionen sowie Bareinzahlungen und -abhebungen + warning: Historische Aktivitäten sind auf den Berichtszeitraum der Flex-Abfrage beschränkt + status: + fetching_accounts: Konten werden von Interactive Brokers abgerufen... + no_accounts_found_title: Keine Konten gefunden. + no_accounts_found_description: Sure konnte im neuesten Flex-Bericht keine IBKR-Konten finden. + available_accounts: + title: Verfügbare Konten + account_type_investment: Investment + account_summary: "%{account_type} • Saldo: %{balance}" + account_id: "Konto-ID: %{account_id}" + link_existing: + description: Oder verknüpfen Sie ein gefundenes IBKR-Konto mit einem bestehenden manuellen Investmentkonto. + manual_account_option: "%{name} (%{balance})" + select_prompt: Konto auswählen... + linked_accounts: + title: Bereits verknüpft + linked_to_html: "Verknüpft mit: %{account}" + buttons: + refresh: Aktualisieren + cancel: Abbrechen + back_to_settings: Zurück zu den Einstellungen + create_selected_accounts: Ausgewählte Konten erstellen + link: Verknüpfen + done: Fertig + sync_status: + no_accounts: Noch keine IBKR-Konten gefunden + all_linked: + one: 1 Konto verknüpft + other: "%{count} Konten verknüpft" + partial: "%{linked} verknüpft, %{unlinked} müssen eingerichtet werden" + select_existing_account: + title: Interactive Brokers-Konto verknüpfen + no_accounts_available: Es sind noch keine nicht verknüpften Interactive Brokers-Konten verfügbar. + run_sync_hint: "Führen Sie unter Einstellungen > Anbieter eine Synchronisierung durch, nachdem Sie Ihre Flex-Abfrage aktualisiert haben." + wait_for_sync: Warten Sie, bis die Synchronisierung zur Kontoerkennung abgeschlossen ist. + balance: Saldo + link: Verknüpfen + cancel: Abbrechen + create: + success: Interactive Brokers erfolgreich konfiguriert. + update: + success: Interactive Brokers-Konfiguration erfolgreich aktualisiert. + destroy: + success: Interactive Brokers-Verbindung zur Löschung vorgemerkt. + select_accounts: + not_configured: Interactive Brokers ist nicht konfiguriert. + link_existing_account: + not_found: Konto oder Interactive Brokers-Konfiguration nicht gefunden. + only_manual_investment: Nur manuelle Investmentkonten können mit Interactive Brokers verknüpft werden. + already_linked: Dieses Interactive Brokers-Konto ist bereits verknüpft. + success: Erfolgreich mit Interactive Brokers-Konto verknüpft. + failed: Verknüpfung mit Interactive Brokers-Konto fehlgeschlagen. + complete_account_setup: + success: + one: "%{count} Interactive Brokers-Konto erfolgreich erstellt." + other: "%{count} Interactive Brokers-Konten erfolgreich erstellt." + none_selected: Es wurden keine Konten ausgewählt. + none_created: Es wurden keine Konten erstellt. diff --git a/config/locales/views/kraken_items/de.yml b/config/locales/views/kraken_items/de.yml new file mode 100644 index 000000000..2a31167b0 --- /dev/null +++ b/config/locales/views/kraken_items/de.yml @@ -0,0 +1,85 @@ +--- +de: + kraken_items: + provider_connection: + default_name: Kraken + default_description: Mit einem Kraken-Börsenkonto verknüpfen + name: "Kraken - %{name}" + description: "Mit %{name} verknüpfen" + create: + default_name: Kraken + success: Erfolgreich mit Kraken verbunden. Ihr Börsenkonto wird synchronisiert. + update: + success: Kraken-Verbindung erfolgreich aktualisiert. + destroy: + success: Kraken-Verbindung zur Löschung vorgemerkt. + select_accounts: + select_connection: Wählen Sie in den Anbietereinstellungen eine Kraken-Verbindung aus. + no_credentials_configured: Fügen Sie Kraken-API-Zugangsdaten hinzu, bevor Sie Konten einrichten. + link_accounts: + select_connection: Wählen Sie eine Kraken-Verbindung aus, bevor Sie Konten verknüpfen. + select_existing_account: + title: Kraken-Konto verknüpfen + no_accounts_found: Keine Kraken-Konten gefunden. + wait_for_sync: Warten Sie, bis Kraken die Synchronisierung abgeschlossen hat. + check_provider_health: Prüfen Sie, ob Ihre Kraken-API-Zugangsdaten gültig sind. + link: Verknüpfen + cancel: Abbrechen + link_existing_account: + success: Erfolgreich mit Kraken-Konto verknüpft + select_connection: Wählen Sie eine Kraken-Verbindung aus, bevor Sie Konten verknüpfen. + errors: + only_manual: Nur manuelle Krypto-Börsenkonten ohne bestehende Anbieterverknüpfung können mit Kraken verknüpft werden + invalid_kraken_account: Ungültiges Kraken-Konto + kraken_account_already_linked: Dieses Kraken-Konto ist bereits verknüpft + setup_accounts: + title: Kraken-Konto importieren + subtitle: Wählen Sie das zu verfolgende Börsenkonto + instructions: Kraken importiert für diese Verbindung ein kombiniertes Krypto-Börsenkonto, ausschließlich mit Beständen und Spot-Trade-Ausführungen. + no_accounts: Alle Kraken-Konten wurden importiert. + accounts_count: + one: "%{count} Konto verfügbar" + other: "%{count} Konten verfügbar" + select_all: Alle auswählen + import_selected: Ausgewählte importieren + cancel: Abbrechen + creating: Importiere... + complete_account_setup: + success: + one: "%{count} Konto importiert" + other: "%{count} Konten importiert" + none_selected: Keine Konten ausgewählt + no_accounts: Keine Konten zum Import + kraken_item: + provider_name: Kraken + syncing: Synchronisiere... + reconnect: Zugangsdaten müssen aktualisiert werden + deletion_in_progress: Wird gelöscht... + sync_status: + no_accounts: Keine Konten gefunden + all_synced: + one: "%{count} Konto synchronisiert" + other: "%{count} Konten synchronisiert" + partial_sync: "%{linked_count} synchronisiert, %{unlinked_count} müssen eingerichtet werden" + status: "Zuletzt synchronisiert vor %{timestamp}" + status_with_summary: "Zuletzt synchronisiert vor %{timestamp} – %{summary}" + status_never: Noch nie synchronisiert + delete: Löschen + no_accounts_title: Keine Konten gefunden + no_accounts_message: Ihr Kraken-Börsenkonto erscheint hier nach der Synchronisierung. + setup_needed: Konto bereit zum Import + setup_description: Importieren Sie diese Kraken-Verbindung als Krypto-Börsenkonto. + setup_action: Konto importieren + import_accounts_menu: Konto importieren + stale_rate_warning: "Der Saldo ist ein Näherungswert, da der genaue Wechselkurs für %{date} nicht verfügbar war. Er wird bei der nächsten Synchronisierung aktualisiert." + kraken_item: + syncer: + checking_credentials: Zugangsdaten werden geprüft... + credentials_invalid: Ungültige Kraken-API-Zugangsdaten. Bitte überprüfen Sie Ihren API-Schlüssel und Ihr Secret. + importing_accounts: Konten werden von Kraken importiert... + checking_configuration: Kontokonfiguration wird geprüft... + accounts_need_setup: + one: "%{count} Konto muss eingerichtet werden" + other: "%{count} Konten müssen eingerichtet werden" + processing_accounts: Kontodaten werden verarbeitet... + calculating_balances: Salden werden berechnet... diff --git a/config/locales/views/messages/de.yml b/config/locales/views/messages/de.yml new file mode 100644 index 000000000..17e199c34 --- /dev/null +++ b/config/locales/views/messages/de.yml @@ -0,0 +1,6 @@ +--- +de: + messages: + chat_form: + placeholder: "Fragen Sie irgendetwas ..." + disclaimer: "KI-Antworten dienen nur zur Information. Keine Finanzberatung!" diff --git a/config/locales/views/pending_duplicate_merges/de.yml b/config/locales/views/pending_duplicate_merges/de.yml new file mode 100644 index 000000000..6a7a5ef60 --- /dev/null +++ b/config/locales/views/pending_duplicate_merges/de.yml @@ -0,0 +1,21 @@ +--- +de: + pending_duplicate_merges: + create: + no_posted_selected: Bitte wählen Sie eine gebuchte Transaktion zum Zusammenführen aus + invalid_transaction: Ungültige Transaktion zum Zusammenführen ausgewählt + merge_success: Ausstehende Transaktion mit gebuchter Transaktion zusammengeführt + merge_failed: Transaktionen konnten nicht zusammengeführt werden + set_transaction: + pending_only: Diese Funktion ist nur für ausstehende Transaktionen verfügbar + new: + title: Mit gebuchter Transaktion zusammenführen + warning_title: Manuelles Zusammenführen von Duplikaten + warning_description: Verwenden Sie dies, um eine ausstehende Transaktion manuell mit ihrer gebuchten Version zusammenzuführen. Dadurch wird die ausstehende Transaktion gelöscht und nur die gebuchte beibehalten. + pending_transaction: Ausstehende Transaktion + select_posted: Gebuchte Transaktion zum Zusammenführen auswählen + showing_range: "%{start} - %{end} werden angezeigt" + previous: "← Vorherige 10" + next: "Nächste 10 →" + no_candidates: Keine gebuchten Transaktionen in diesem Konto gefunden. + submit_button: Transaktionen zusammenführen diff --git a/config/locales/views/securities/de.yml b/config/locales/views/securities/de.yml new file mode 100644 index 000000000..7c914d341 --- /dev/null +++ b/config/locales/views/securities/de.yml @@ -0,0 +1,14 @@ +--- +de: + securities: + combobox: + display: "%{symbol} - %{name} (%{exchange})" + exchange_label: "%{symbol} (%{exchange})" + providers: + twelve_data: Twelve Data + yahoo_finance: Yahoo Finance + tiingo: Tiingo + eodhd: EODHD + alpha_vantage: Alpha Vantage + mfapi: MFAPI.in + binance_public: Binance diff --git a/config/locales/views/sophtron_items/de.yml b/config/locales/views/sophtron_items/de.yml new file mode 100644 index 000000000..7701dfd25 --- /dev/null +++ b/config/locales/views/sophtron_items/de.yml @@ -0,0 +1,313 @@ +--- +de: + sophtron_items: + defaults: + name: Sophtron-Verbindung + new: + title: Sophtron verbinden + user_id: Benutzer-ID + user_id_placeholder: Fügen Sie Ihre Sophtron-Benutzer-ID ein + access_key: Zugriffsschlüssel + access_key_placeholder: Fügen Sie Ihren Sophtron-Zugriffsschlüssel ein + connect: Verbinden + cancel: Abbrechen + create: + success: Sophtron-Verbindung erfolgreich erstellt + destroy: + success: Sophtron-Verbindung entfernt + update: + success: Sophtron-Verbindung erfolgreich aktualisiert! Ihre Konten werden erneut verbunden. + errors: + blank_user_id: Bitte geben Sie eine Sophtron-Benutzer-ID ein. + invalid_user_id: Ungültige Benutzer-ID. Bitte prüfen Sie, ob Sie die vollständige Benutzer-ID von Sophtron kopiert haben. + user_id_compromised: Die Benutzer-ID ist möglicherweise kompromittiert, abgelaufen oder bereits verwendet. Bitte erstellen Sie eine neue. + blank_access_key: Bitte geben Sie einen Sophtron-Zugriffsschlüssel ein. + invalid_access_key: Ungültiger Zugriffsschlüssel. Bitte prüfen Sie, ob Sie den vollständigen Zugriffsschlüssel von Sophtron kopiert haben. + access_key_compromised: Der Zugriffsschlüssel ist möglicherweise kompromittiert, abgelaufen oder bereits verwendet. Bitte erstellen Sie einen neuen. + update_failed: "Verbindung konnte nicht aktualisiert werden: %{message}" + unexpected: Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut oder wenden Sie sich an den Support. + edit: + user_id: + label: "Sophtron-Benutzer-ID:" + placeholder: "Fügen Sie hier Ihre Sophtron-Benutzer-ID ein..." + help_text: "Die Benutzer-ID sollte eine lange Zeichenfolge sein, die mit Buchstaben und Zahlen beginnt" + access_key: + label: "Sophtron-Zugriffsschlüssel:" + placeholder: "Fügen Sie hier Ihren Sophtron-Zugriffsschlüssel ein..." + help_text: "Der Zugriffsschlüssel sollte eine lange Zeichenfolge sein, die mit Buchstaben und Zahlen beginnt" + index: + title: Sophtron-Verbindungen + loading: + loading_message: Sophtron-Konten werden geladen... + loading_title: Wird geladen + link_accounts: + all_already_linked: + one: "Das ausgewählte Konto (%{names}) ist bereits verknüpft" + other: "Alle %{count} ausgewählten Konten sind bereits verknüpft: %{names}" + api_error: "API-Verbindungsfehler" + invalid_account_names: + one: "Konto mit leerem Namen kann nicht verknüpft werden" + other: "%{count} Konten mit leeren Namen können nicht verknüpft werden" + link_failed: Konten konnten nicht verknüpft werden + no_accounts_selected: Bitte wählen Sie mindestens ein Konto aus + partial_invalid: "%{created_count} Konto(en) erfolgreich verknüpft, %{already_linked_count} waren bereits verknüpft, %{invalid_count} Konto(en) hatten ungültige Namen" + partial_success: "%{created_count} Konto(en) erfolgreich verknüpft. %{already_linked_count} Konto(en) waren bereits verknüpft: %{already_linked_names}" + success: + one: "%{count} Konto erfolgreich verknüpft" + other: "%{count} Konten erfolgreich verknüpft" + no_credentials_configured: "Bitte konfigurieren Sie zuerst Ihre Sophtron-API-Benutzer-ID und Ihren Zugriffsschlüssel in den Anbietereinstellungen." + no_accounts_found: Keine Konten gefunden. Bitte überprüfen Sie Ihre API-Schlüssel-Konfiguration. + no_access_key: Sophtron-Zugriffsschlüssel ist nicht konfiguriert. Bitte konfigurieren Sie ihn in den Einstellungen. + no_user_id: Sophtron-Benutzer-ID ist nicht konfiguriert. Bitte konfigurieren Sie sie in den Einstellungen. + no_institution_connected: Bitte verbinden Sie zuerst ein Bankinstitut mit Sophtron. + connect: + cancel: Abbrechen + captcha: Captcha + connect: Verbinden + institution_search_label: Institut + institution_search_placeholder: Nach Bankname suchen + no_institutions: Keine passenden Institute gefunden. + password: Passwort + search: Suchen + search_too_short: Geben Sie mindestens zwei Zeichen für die Suche ein. + title: Sophtron-Institut verbinden + username: Benutzername + connect_institution: + api_error: "Sophtron-Verbindung fehlgeschlagen: %{message}" + missing_parameters: Wählen Sie ein Institut aus und geben Sie Ihre Bank-Anmeldedaten ein. + connection_status: + api_error: "API-Verbindungsfehler: %{message}" + attempt: "Versuch %{attempt} von %{max}" + check_again: Erneut prüfen + failed: Sophtron konnte diese Institutsverbindung nicht abschließen. + failed_timeout: Bei Sophtron ist eine Zeitüberschreitung aufgetreten, während das Institut die Anmeldung abschloss. + timeout: Sophtron hat die Verbindung nicht innerhalb der erwarteten Zeit abgeschlossen. Sie können erneut prüfen oder später erneut versuchen. + title: Sophtron wird verbunden + waiting: Sophtron verbindet sich noch mit Ihrem Institut. + mfa: + captcha: Captcha-Text + captcha_alt: Sophtron-Captcha + phone_confirmed: Ich habe telefonisch bestätigt + submit: Absenden + title: Sophtron-Verifizierung + token: Verifizierungscode + submit_mfa: + api_error: "Verifizierung fehlgeschlagen: %{message}" + invalid_security_answers: Sicherheitsantworten fehlen oder sind zu lang. + unknown_challenge: Unbekannter Sophtron-Verifizierungsschritt. + sophtron_item: + accounts_need_setup: Konten müssen eingerichtet werden + automatic_sync: Automatische Synchronisierung verwenden + delete: Verbindung löschen + deletion_in_progress: Löschung läuft... + error: Fehler + no_accounts_description: Diese Verbindung hat noch keine verknüpften Konten. + no_accounts_title: Keine Konten + manual_sync: Manuelle Synchronisierung + manual_sync_action: Manuelle Synchronisierung erforderlich machen + manual_sync_action_for: "Manuelle Synchronisierung für %{institution} erforderlich machen" + automatic_sync_for: "Automatische Synchronisierung für %{institution} verwenden" + setup_action: Neue Konten einrichten + setup_description: "%{linked} von %{total} Konten verknüpft. Wählen Sie Kontotypen für Ihre neu importierten Sophtron-Konten." + setup_needed: Neue Konten bereit zur Einrichtung + status: "Vor %{timestamp} synchronisiert" + status_never: Noch nie synchronisiert + status_with_summary: "Zuletzt synchronisiert vor %{timestamp} • %{summary}" + sync_now: Jetzt synchronisieren + syncing: Synchronisiere... + total: Gesamt + unlinked: Nicht verknüpft + preload_accounts: + preload_accounts: Konten vorab laden + api_error: "API-Verbindungsfehler" + unexpected_error: "Ein unerwarteter Fehler ist aufgetreten" + no_credentials_configured: "Bitte konfigurieren Sie zuerst Ihre Sophtron-API-Benutzer-ID und Ihren Zugriffsschlüssel in den Anbietereinstellungen." + no_accounts_found: Keine Konten gefunden. Bitte überprüfen Sie Ihre API-Schlüssel-Konfiguration. + no_access_key: Sophtron-Zugriffsschlüssel ist nicht konfiguriert. Bitte konfigurieren Sie ihn in den Einstellungen. + no_user_id: Sophtron-Benutzer-ID ist nicht konfiguriert. Bitte konfigurieren Sie sie in den Einstellungen. + select_accounts: + accounts_selected: Konten ausgewählt + api_error: "API-Verbindungsfehler" + unexpected_error: "Ein unerwarteter Fehler ist aufgetreten" + cancel: Abbrechen + configure_name_in_sophtron: Import nicht möglich – bitte konfigurieren Sie den Kontonamen in Sophtron + description: Wählen Sie die Konten aus, die Sie mit Ihrem %{product_name}-Konto verknüpfen möchten. + link_accounts: Ausgewählte Konten verknüpfen + no_accounts_found: Keine Konten gefunden. Bitte überprüfen Sie Ihre API-Schlüssel-Konfiguration. + no_access_key: Sophtron-Zugriffsschlüssel ist nicht konfiguriert. Bitte konfigurieren Sie ihn in den Einstellungen. + no_user_id: Sophtron-Benutzer-ID ist nicht konfiguriert. Bitte konfigurieren Sie sie in den Einstellungen. + no_credentials_configured: "Bitte konfigurieren Sie zuerst Ihre Sophtron-API-Benutzer-ID und Ihren Zugriffsschlüssel in den Anbietereinstellungen." + no_institution_connected: Bitte verbinden Sie zuerst ein Bankinstitut mit Sophtron. + no_name_placeholder: "(Kein Name)" + title: Sophtron-Konten auswählen + select_existing_account: + account_already_linked: Dieses Konto ist bereits mit einem Anbieter verknüpft + all_accounts_already_linked: Alle Sophtron-Konten sind bereits verknüpft + api_error: "API-Verbindungsfehler" + cancel: Abbrechen + configure_name_in_sophtron: Import nicht möglich – bitte konfigurieren Sie den Kontonamen in Sophtron + description: Wählen Sie ein Sophtron-Konto aus, das mit diesem Konto verknüpft werden soll. Transaktionen werden automatisch synchronisiert und dedupliziert. + link_account: Konto verknüpfen + no_account_specified: Kein Konto angegeben + no_accounts_found: Keine Sophtron-Konten gefunden. Bitte überprüfen Sie Ihre API-Schlüssel-Konfiguration. + no_access_key: Sophtron-Zugriffsschlüssel ist nicht konfiguriert. Bitte konfigurieren Sie ihn in den Einstellungen. + no_user_id: Sophtron-Benutzer-ID ist nicht konfiguriert. Bitte konfigurieren Sie sie in den Einstellungen. + no_institution_connected: Bitte verbinden Sie zuerst ein Bankinstitut mit Sophtron. + no_name_placeholder: "(Kein Name)" + title: "%{account_name} mit Sophtron verknüpfen" + unexpected_error: "Ein unerwarteter Fehler ist aufgetreten" + link_existing_account: + account_already_linked: Dieses Konto ist bereits mit einem Anbieter verknüpft + api_error: "API-Verbindungsfehler" + unexpected_error: "Ein unerwarteter Fehler ist aufgetreten" + invalid_account_name: Konto mit leerem Namen kann nicht verknüpft werden + sophtron_account_already_linked: Dieses Sophtron-Konto ist bereits mit einem anderen Konto verknüpft + sophtron_account_not_found: Sophtron-Konto nicht gefunden + missing_parameters: Erforderliche Parameter fehlen + no_institution_connected: Bitte verbinden Sie zuerst ein Bankinstitut mit Sophtron. + success: "%{account_name} erfolgreich mit Sophtron verknüpft" + setup_accounts: + account_type_label: "Kontotyp:" + all_accounts_linked: "Alle Ihre Sophtron-Konten wurden bereits eingerichtet." + api_error: "API-Verbindungsfehler" + unexpected_error: "Ein unerwarteter Fehler ist aufgetreten" + fetch_failed: "Konten konnten nicht abgerufen werden" + no_accounts_to_setup: "Keine Konten zum Einrichten" + no_access_key: "Sophtron-Zugriffsschlüssel ist nicht konfiguriert. Bitte überprüfen Sie Ihre Verbindungseinstellungen." + no_user_id: "Sophtron-Benutzer-ID ist nicht konfiguriert. Bitte überprüfen Sie Ihre Verbindungseinstellungen." + no_institution_connected: "Sophtron-Institut ist noch nicht verbunden." + account_types: + skip: Dieses Konto überspringen + depository: Giro- oder Sparkonto + credit_card: Kreditkarte + investment: Investmentkonto + loan: Darlehen oder Hypothek + other_asset: Sonstiger Vermögenswert + subtype_labels: + depository: "Kontountertyp:" + credit_card: "" + investment: "Investmenttyp:" + loan: "Darlehenstyp:" + other_asset: "" + subtype_messages: + credit_card: "Kreditkarten werden automatisch als Kreditkartenkonten eingerichtet." + other_asset: "Für sonstige Vermögenswerte sind keine zusätzlichen Optionen erforderlich." + balance: Saldo + cancel: Abbrechen + choose_account_type: "Wählen Sie den richtigen Kontotyp für jedes Sophtron-Konto:" + create_accounts: Konten erstellen + creating_accounts: Konten werden erstellt... + historical_data_range: "Historischer Datenbereich:" + subtitle: Wählen Sie die richtigen Kontotypen für Ihre importierten Konten + sync_start_date_help: Wählen Sie aus, wie weit zurück Sie den Transaktionsverlauf synchronisieren möchten. Maximal 3 Jahre Verlauf verfügbar. + sync_start_date_label: "Transaktionen synchronisieren ab:" + title: Richten Sie Ihre Sophtron-Konten ein + complete_account_setup: + all_skipped: "Alle Konten wurden übersprungen. Es wurden keine Konten erstellt." + creation_failed: "Konten konnten nicht erstellt werden" + api_error: "API-Verbindungsfehler" + unexpected_error: "Ein unerwarteter Fehler ist aufgetreten" + no_accounts: "Keine Konten zum Einrichten." + success: "%{count} Konto(en) erfolgreich erstellt." + sync: + already_running: Eine manuelle Sophtron-Synchronisierung läuft bereits. + api_error: "Manuelle Sophtron-Synchronisierung fehlgeschlagen: %{message}" + failed: Manuelle Sophtron-Synchronisierung fehlgeschlagen + no_linked_accounts: Dieses Sophtron-Institut hat keine verknüpften Konten zum Synchronisieren. + processing_failed: Die manuelle Sophtron-Synchronisierung konnte die aktualisierten Transaktionen nicht verarbeiten. + success: Synchronisierung gestartet + toggle_manual_sync: + success_disabled: Sophtron-Institut wird automatisch synchronisiert. + success_enabled: Sophtron-Institut erfordert nun eine manuelle Synchronisierung. + manual_sync_complete: + close: Schließen + description: Die Kontosalden werden im Hintergrund fertig aktualisiert. + message: Die Transaktionen wurden nach der Sophtron-Verifizierung heruntergeladen. + title: Sophtron-Synchronisierung gestartet + sophtron_setup_required: + title: Sophtron-Einrichtung erforderlich + message: > + Um die Einrichtung Ihrer Sophtron-Verbindung abzuschließen, gehen Sie bitte zur Seite mit den Anbietereinstellungen und folgen Sie den Anweisungen, um Ihre Sophtron-Verbindung zu autorisieren und zu konfigurieren. + go_to_provider_settings: Zu den Anbietereinstellungen + heading: "Benutzer-ID und Zugriffsschlüssel nicht konfiguriert" + description: "Bevor Sie Sophtron-Konten verknüpfen können, müssen Sie Ihre Sophtron-Benutzer-ID und Ihren Zugriffsschlüssel konfigurieren." + setup_steps_title: "Einrichtungsschritte:" + step_1_html: "Gehen Sie zu Einstellungen → Bank-Sync-Anbieter" + step_2_html: "Suchen Sie den Bereich Sophtron" + step_3_html: "Geben Sie Ihre Sophtron-Benutzer-ID und Ihren Zugriffsschlüssel ein" + step_4: "Kehren Sie hierher zurück, um Ihre Konten zu verknüpfen" + api_error: + title: "Sophtron-Verbindungsfehler" + unable_to_connect: "Verbindung zu Sophtron nicht möglich" + institution_unable_to_connect: "Verbindung zum Institut nicht möglich" + common_issues_title: "Häufige Probleme:" + incorrect_user_id: "Falsche Benutzer-ID: Überprüfen Sie Ihre Benutzer-ID in den Anbietereinstellungen" + invalid_access_key: "Ungültiger Zugriffsschlüssel: Überprüfen Sie Ihren Zugriffsschlüssel in den Anbietereinstellungen" + expired_credentials: "Abgelaufene Zugangsdaten: Erstellen Sie eine neue Benutzer-ID und einen neuen Zugriffsschlüssel bei Sophtron" + network_issue: "Netzwerkproblem: Überprüfen Sie Ihre Internetverbindung" + service_down: "Dienst nicht verfügbar: Die Sophtron-API ist möglicherweise vorübergehend nicht verfügbar" + bad_credentials: "Bank-Anmeldedaten: Prüfen Sie, ob Benutzername und Passwort korrekt sind" + verification_code: "Verifizierungscode: Stellen Sie sicher, dass der neueste Code vor seinem Ablauf eingegeben wurde" + institution_timeout: "Zeitüberschreitung des Instituts: Die Bank-Anmeldeseite wurde nicht rechtzeitig abgeschlossen" + unsupported_mfa: "MFA-Unterstützung: Sophtron unterstützt möglicherweise den aktuellen Verifizierungsablauf dieses Instituts nicht" + check_provider_settings: "Anbietereinstellungen prüfen" + try_again: "Erneut verbinden" + select_option: "%{type} auswählen" + subtype: "Untertyp" + type: "Typ" + sophtron_panel: + setup_instructions_title: "Einrichtungsanweisungen:" + setup_instructions: + step_1_html: 'Besuchen Sie Sophtron, um Ihre API-Zugangsdaten zu erhalten' + step_2: "Kopieren Sie Ihre Benutzer-ID und Ihren Zugriffsschlüssel aus Ihren Sophtron-Kontoeinstellungen" + step_3: "Fügen Sie die Zugangsdaten unten ein und klicken Sie auf Speichern; Sure erstellt oder verwendet Ihre Sophtron-Kunden-ID automatisch" + field_descriptions_title: "Feldbeschreibungen:" + field_descriptions: + user_id_html: "Benutzer-ID: Ihre Sophtron-Benutzer-ID-Zugangsdaten" + access_key_html: "Zugriffsschlüssel: Ihre Sophtron-Zugriffsschlüssel-Zugangsdaten" + base_url_html: "Basis-URL: Die URL des Sophtron-API-Endpunkts, üblicherweise https://api.sophtron.com/api" + fields: + user_id: + label: "Benutzer-ID" + placeholder_new: "Fügen Sie Ihre Sophtron-Benutzer-ID ein" + placeholder_edit: "••••••••" + access_key: + label: "Zugriffsschlüssel" + placeholder_new: "Fügen Sie Ihren Sophtron-Zugriffsschlüssel ein" + placeholder_edit: "••••••••" + base_url: + label: "Basis-URL" + placeholder: "https://api.sophtron.com/api" + save: "Konfiguration speichern" + update: "Konfiguration aktualisieren" + syncer: + manual_sync_required: "Für dieses Institut ist eine manuelle Sophtron-Synchronisierung erforderlich; diese Konten werden während der automatischen Synchronisierung übersprungen." + importing_accounts: "Konten werden von Sophtron importiert..." + checking_account_configuration: "Kontokonfiguration wird geprüft..." + accounts_need_setup: "%{count} Konto(en) müssen eingerichtet werden" + processing_transactions: "Transaktionen für verknüpfte Konten werden verarbeitet..." + calculating_balances: "Salden für verknüpfte Konten werden berechnet..." + sophtron_entry: + processor: + unknown_transaction: "Unbekannte Transaktion" + render_connection_timeout: + timeout: "Zeitüberschreitung der Verbindung. Bitte versuchen Sie es erneut." + redirect_after_account_link: + invalid_account_names: + one: "%{count} Konto mit leerem Namen kann nicht verknüpft werden" + other: "%{count} Konten mit leeren Namen können nicht verknüpft werden" + partial_invalid: "%{created_count} Konto(en) verknüpft. %{already_linked_count} bereits verknüpft, %{invalid_count} hatten ungültige Namen." + partial_success: "%{created_count} Konto(en) verknüpft. %{already_linked_count} Konto(en) waren bereits verknüpft." + success: + one: "%{count} Konto erfolgreich verknüpft." + other: "%{count} Konten erfolgreich verknüpft." + all_already_linked: + one: "Das ausgewählte Konto ist bereits verknüpft" + other: "Alle %{count} ausgewählten Konten sind bereits verknüpft" + link_failed: "Konten konnten nicht verknüpft werden" + start_manual_sync: + already_running: "Eine Synchronisierung läuft bereits." + no_linked_accounts: "Keine verknüpften Konten zum Synchronisieren verfügbar." + api_error: "API-Fehler: %{message}" + start_manual_sync_for_account: + failed: "Konto konnte nicht synchronisiert werden" diff --git a/config/locales/views/splits/de.yml b/config/locales/views/splits/de.yml new file mode 100644 index 000000000..79ea40ab9 --- /dev/null +++ b/config/locales/views/splits/de.yml @@ -0,0 +1,47 @@ +--- +de: + splits: + new: + title: Transaktion aufteilen + description: Teilen Sie diese Transaktion in mehrere Einträge mit unterschiedlichen Kategorien und Beträgen auf. + submit: Transaktion aufteilen + cancel: Abbrechen + add_row: Aufteilung hinzufügen + remove_row: Entfernen + remaining: Verbleibend + amounts_must_match: Die Teilbeträge müssen dem ursprünglichen Transaktionsbetrag entsprechen. + name_label: Name + name_placeholder: Name der Aufteilung + amount_label: Betrag + category_label: Kategorie + uncategorized: "(nicht kategorisiert)" + original_name: "Name:" + original_date: "Datum:" + original_amount: "Betrag" + split_number: "Aufteilung #%{number}" + create: + success: Transaktion erfolgreich aufgeteilt + not_splittable: Diese Transaktion kann nicht aufgeteilt werden. + destroy: + success: Aufteilung der Transaktion erfolgreich aufgehoben + show: + title: Aufgeteilte Einträge + description: Diese Transaktion wurde in die folgenden Einträge aufgeteilt. + button_title: Transaktion aufteilen + button_description: Teilen Sie diese Transaktion in mehrere Einträge mit unterschiedlichen Kategorien und Beträgen auf. + button: Aufteilen + unsplit_title: Aufteilung aufheben + unsplit_button: Aufteilung aufheben + unsplit_confirm: Dadurch werden alle aufgeteilten Einträge entfernt und die ursprüngliche Transaktion wiederhergestellt. + edit: + title: Aufteilung bearbeiten + description: Bearbeiten Sie die aufgeteilten Einträge für diese Transaktion. + submit: Aufteilung aktualisieren + not_split: Diese Transaktion ist nicht aufgeteilt. + update: + success: Aufteilung erfolgreich aktualisiert + child: + title: Teil einer Aufteilung + description: Dieser Eintrag ist Teil einer aufgeteilten Transaktion. + edit_split: Aufteilung bearbeiten + unsplit: Aufteilung aufheben diff --git a/config/locales/views/transfer_matches/de.yml b/config/locales/views/transfer_matches/de.yml new file mode 100644 index 000000000..63b930aa6 --- /dev/null +++ b/config/locales/views/transfer_matches/de.yml @@ -0,0 +1,24 @@ +--- +de: + transfer_matches: + create: + success: Übertragung erstellt + new: + header: + title: Übertragung oder Zahlung zuordnen + subtitle: Ordnen Sie die entsprechende Transaktion in einem anderen Konto zu oder erstellen Sie eine, falls keine vorhanden ist. + from_account: Von Konto + from_account_named: "Von Konto: %{name}" + to_account: Zu Konto + to_account_named: "Zu Konto: %{name}" + outflow_transaction: Abgangstransaktion + inflow_transaction: Zugangstransaktion + create_transfer_match: Übertragungszuordnung erstellen + matching_fields: + select_method: Wählen Sie eine Methode zum Zuordnen Ihrer Transaktionen. + match_existing_recommended: Vorhandene Transaktion zuordnen (empfohlen) + create_new_transaction: Neue Transaktion erstellen + matching_method: Zuordnungsmethode + matching_transaction: Zuzuordnende Transaktion + target_account: Zielkonto + no_matching_transactions: Wir konnten in Ihren anderen Konten keine zuzuordnenden Transaktionen finden. Bitte wählen Sie ein Konto aus, und wir erstellen für Sie eine neue Zugangstransaktion. From 5abf9cb53772fc731b8959ec11b41e7d8e9d8a9e Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Wed, 3 Jun 2026 12:02:50 +0200 Subject: [PATCH 05/20] =?UTF-8?q?fix(ds):=20dark-mode=20token=20parity=20?= =?UTF-8?q?=E2=80=94=20contrast=20&=20state=20fixes=20(#2139)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ds): dark-mode token parity — text, borders, checkbox, toggle, over-budget badge Dark-mode contrast/parity pass (design-review epic #2134, follow-up to #1736): - text-subdued (dark): gray-500 -> gray-400; faint/eyebrow text ~3.6:1 -> ~6.7:1 (clears AA), staying below text-secondary so hierarchy holds. - border-primary/secondary/subdued (dark): +1 alpha-white step each; restores group-card edges and in-card row hairlines (border-primary 1.86:1 -> 2.8:1). Alpha keeps them surface-relative across any dark bg. - Dark checkbox: unchecked was a solid white square (read as already-selected) — now a transparent outlined box; checked/indeterminate use a white fill with a #171717 glyph (was #808080, ~2:1); added an explicit indeterminate dash; disabled muted to gray-700. - Toggle (light off-state): track gray-100 -> gray-300 plus a thumb shadow; the white-thumb-on-white-track invisible off-state now reads (dark off-track was already hardened to gray-700). - Over-budget badge: text-red-500 -> text-destructive (theme-aware, matching the on-track/near-limit siblings); in-situ dark contrast 4.18:1 -> 4.55:1 (AA). - Correct invalid icon color keys (red/yellow/green -> destructive/warning/success) on the three budget status badges. Verified in-browser, light+dark: isolated checkbox states, /accounts card borders, /reports muted text, toggle off-state, /budgets over-budget badge (in-situ 4.55:1). * fix(ds): reconcile destructive color + fix filled-pill contrast Continues the dark-parity pass (#2134): - Reconcile destructive: border-destructive and button-bg-destructive were red-500 while the destructive text/icon token was red-600. Unify on red-600 (light) / red-400 (dark) across text, border, and button — the text token can't drop to red-500 (3.96:1 on white, fails AA), so border/button move up instead. White-on-destructive-button 3.96:1 -> 4.36:1 (AA-large); hover red-600 -> red-700. - DS::Pill filled style: deepen the fill tone-500 -> tone-700. White label text on tone-500 failed AA on nearly every tone (amber 2.35:1, green 2.62:1, red 3.95:1); tone-700 clears it (amber 5.43, green 4.30, red 5.86, others 6.4-12) in both themes and removes the dark-surface glare. Date-input calendar glyph in dark verified already-correct (existing invert(1) rules; color-scheme is normal, so no conflict) — no change needed. Verified in-browser: real .button-bg-destructive (red-600) + filled pills, all tones, light and dark. * fix(ds): destructive button consumes the reconciled red-600 Follow-up to the destructive reconcile in this branch: DS::Buttonish's destructive variant still used raw bg-red-500 / hover:bg-red-600, so destructive *buttons* didn't match the reconciled destructive text/border (red-600). Align to red-600 / hover red-700 (light); dark unchanged (red-400 / red-500). White-on-red-600 = 4.37:1 (AA-large), consistent with the rest of the destructive family. * refactor(ds): tokenize budget-category badge + bar backgrounds Status-badge foregrounds already used semantic tokens (text-destructive/ warning/success) but backgrounds + progress-bar fills stayed on the raw palette (bg-red-500/10, bg-yellow-500, ...). Switch to the matching semantic tokens (bg-destructive/10, bg-warning, bg-success) — same value in light, now theme-aware in dark. Mirrors DS::Alert. Addresses CodeRabbit/Codex on #2139. --- .../sure-design-system/_generated.css | 16 +++++++------- .../sure-design-system/components.css | 21 +++++++++++++------ app/components/DS/buttonish.rb | 2 +- app/components/DS/pill.rb | 7 ++++++- app/components/DS/toggle.rb | 2 +- .../_budget_category.html.erb | 5 ++--- design/tokens/sure.tokens.json | 16 +++++++------- 7 files changed, 41 insertions(+), 28 deletions(-) diff --git a/app/assets/tailwind/sure-design-system/_generated.css b/app/assets/tailwind/sure-design-system/_generated.css index 79ae54d43..355025e3b 100644 --- a/app/assets/tailwind/sure-design-system/_generated.css +++ b/app/assets/tailwind/sure-design-system/_generated.css @@ -25,7 +25,7 @@ --color-container-inset: var(--color-gray-50); --color-container-inset-hover: var(--color-gray-100); --color-nav-indicator: var(--color-black); - --color-toggle-track: var(--color-gray-100); + --color-toggle-track: var(--color-gray-300); --color-destructive-subtle: var(--color-red-200); --color-gray-25: #FAFAFA; --color-gray-50: #F7F7F7; @@ -294,7 +294,7 @@ @apply text-gray-400; @variant theme-dark { - @apply text-gray-500; + @apply text-gray-400; } } @@ -342,7 +342,7 @@ @apply border-alpha-black-300; @variant theme-dark { - @apply border-alpha-white-400; + @apply border-alpha-white-500; } } @@ -350,7 +350,7 @@ @apply border-alpha-black-200; @variant theme-dark { - @apply border-alpha-white-300; + @apply border-alpha-white-400; } } @@ -362,7 +362,7 @@ @apply border-alpha-black-50; @variant theme-dark { - @apply border-alpha-white-100; + @apply border-alpha-white-200; } } @@ -375,7 +375,7 @@ } @utility border-destructive { - @apply border-red-500; + @apply border-red-600; @variant theme-dark { @apply border-red-400; @@ -447,7 +447,7 @@ } @utility button-bg-destructive { - @apply bg-red-500; + @apply bg-red-600; @variant theme-dark { @apply bg-red-400; @@ -455,7 +455,7 @@ } @utility button-bg-destructive-hover { - @apply bg-red-600; + @apply bg-red-700; @variant theme-dark { @apply bg-red-500; diff --git a/app/assets/tailwind/sure-design-system/components.css b/app/assets/tailwind/sure-design-system/components.css index 7d814388f..21a133c06 100644 --- a/app/assets/tailwind/sure-design-system/components.css +++ b/app/assets/tailwind/sure-design-system/components.css @@ -109,18 +109,27 @@ @variant theme-dark { &[type='checkbox'] { - @apply ring-gray-900 checked:text-white; - background-color: var(--color-gray-100); + @apply ring-gray-900 border-alpha-white-300; + background-color: transparent; } &[type='checkbox']:disabled { - @apply cursor-not-allowed opacity-80; - background-color: var(--color-gray-600); + @apply cursor-not-allowed opacity-80 border-transparent; + background-color: var(--color-gray-700); + } + + &[type='checkbox']:checked, + &[type='checkbox']:indeterminate { + @apply border-transparent; + background-color: var(--color-gray-100); } &[type='checkbox']:checked { - background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='%23808080' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); - background-color: var(--color-gray-100); + background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='%23171717' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); + } + + &[type='checkbox']:indeterminate { + background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='%23171717' xmlns='http://www.w3.org/2000/svg'%3e%3crect x='3.5' y='7' width='9' height='2' rx='1'/%3e%3c/svg%3e"); } } } diff --git a/app/components/DS/buttonish.rb b/app/components/DS/buttonish.rb index d7fa580a6..653151f95 100644 --- a/app/components/DS/buttonish.rb +++ b/app/components/DS/buttonish.rb @@ -9,7 +9,7 @@ class DS::Buttonish < DesignSystemComponent icon_classes: "text-primary" }, destructive: { - container_classes: "text-inverse bg-red-500 theme-dark:bg-red-400 hover:bg-red-600 theme-dark:hover:bg-red-500 disabled:bg-red-200 theme-dark:disabled:bg-red-600", + container_classes: "text-inverse bg-red-600 theme-dark:bg-red-400 hover:bg-red-700 theme-dark:hover:bg-red-500 disabled:bg-red-200 theme-dark:disabled:bg-red-600", icon_classes: "text-inverse" }, outline: { diff --git a/app/components/DS/pill.rb b/app/components/DS/pill.rb index b04ef61d6..c45bf056c 100644 --- a/app/components/DS/pill.rb +++ b/app/components/DS/pill.rb @@ -93,8 +93,13 @@ class DS::Pill < DesignSystemComponent p = palette case style when :filled + # Filled = solid / high-emphasis. The tone-500 fill fails white-label AA on + # the brighter tones (amber 2.4:1, green 2.6:1, red 4.0:1) and glares on dark + # surfaces. Deepen to tone-700 — every `fill` is a `*-500`, so derive -700 — + # so the white label clears AA on every tone in both themes. + strong_fill = p[:fill].sub("-500)", "-700)") <<~CSS.strip.gsub(/\s+/, " ") - background-color: #{p[:fill]}; + background-color: #{strong_fill}; color: var(--color-white); border-color: transparent; CSS diff --git a/app/components/DS/toggle.rb b/app/components/DS/toggle.rb index e45b69051..b1a47da4a 100644 --- a/app/components/DS/toggle.rb +++ b/app/components/DS/toggle.rb @@ -24,7 +24,7 @@ class DS::Toggle < DesignSystemComponent # `prefers-reduced-motion`; reduced-motion users get a snap. "motion-safe:transition-colors motion-safe:duration-300", "after:content-[''] after:block after:bg-white after:absolute after:rounded-full", - "after:top-0.5 after:left-0.5 after:w-4 after:h-4", + "after:top-0.5 after:left-0.5 after:w-4 after:h-4 after:shadow-sm", "motion-safe:after:transition-transform motion-safe:after:duration-300 motion-safe:after:ease-in-out", "peer-checked:bg-success peer-checked:after:translate-x-4", # Focus ring driven from the sr-only input via `peer-focus-visible:`. diff --git a/app/views/budget_categories/_budget_category.html.erb b/app/views/budget_categories/_budget_category.html.erb index 6b55b971e..647f2e91b 100644 --- a/app/views/budget_categories/_budget_category.html.erb +++ b/app/views/budget_categories/_budget_category.html.erb @@ -38,7 +38,7 @@ <%# Progress Bar %>
- <% bar_color = budget_category.over_budget? ? "bg-red-500" : (budget_category.near_limit? ? "bg-yellow-500" : "bg-green-500") %> + <% bar_color = budget_category.over_budget? ? "bg-destructive" : (budget_category.near_limit? ? "bg-warning" : "bg-success") %>
@@ -67,8 +67,7 @@
<%= t("reports.budget_performance.suggested_daily", amount: daily_info[:amount].format, - days: daily_info[:days_remaining]) - %> + days: daily_info[:days_remaining]) %>
<% end %> <% end %> diff --git a/design/tokens/sure.tokens.json b/design/tokens/sure.tokens.json index bf36e8743..82b4a16fe 100644 --- a/design/tokens/sure.tokens.json +++ b/design/tokens/sure.tokens.json @@ -35,7 +35,7 @@ "container-inset": { "$value": "{color.gray.50}", "$type": "color", "$extensions": { "sure.dark": "{color.gray.800}" } }, "container-inset-hover": { "$value": "{color.gray.100}", "$type": "color", "$extensions": { "sure.dark": "{color.gray.700}" } }, "nav-indicator": { "$value": "{color.black}", "$type": "color", "$extensions": { "sure.dark": "{color.white}" } }, - "toggle-track": { "$value": "{color.gray.100}", "$type": "color", "$extensions": { "sure.dark": "{color.gray.700}" } }, + "toggle-track": { "$value": "{color.gray.300}", "$type": "color", "$extensions": { "sure.dark": "{color.gray.700}" } }, "destructive-subtle": { "$value": "{color.red.200}", "$type": "color", "$extensions": { "sure.dark": "{color.red.800}" } }, "gray": { @@ -285,7 +285,7 @@ "text-primary": { "$type": "utility", "$value": "{color.gray.900}", "$extensions": { "sure.utility": { "prefix": "text" }, "sure.dark": "{color.white}" } }, "text-inverse": { "$type": "utility", "$value": "{color.white}", "$extensions": { "sure.utility": { "prefix": "text" }, "sure.dark": "{color.gray.900}" } }, "text-secondary": { "$type": "utility", "$value": "{color.gray.500}", "$extensions": { "sure.utility": { "prefix": "text" }, "sure.dark": "{color.gray.300}" } }, - "text-subdued": { "$type": "utility", "$value": "{color.gray.400}", "$extensions": { "sure.utility": { "prefix": "text" }, "sure.dark": "{color.gray.500}" } }, + "text-subdued": { "$type": "utility", "$value": "{color.gray.400}", "$extensions": { "sure.utility": { "prefix": "text" }, "sure.dark": "{color.gray.400}" } }, "shadow-border-xs": { "$type": "utility", @@ -328,12 +328,12 @@ } }, - "border-primary": { "$type": "utility", "$value": "{color.alpha-black.300}", "$extensions": { "sure.utility": { "prefix": "border" }, "sure.dark": "{color.alpha-white.400}" } }, - "border-secondary": { "$type": "utility", "$value": "{color.alpha-black.200}", "$extensions": { "sure.utility": { "prefix": "border" }, "sure.dark": "{color.alpha-white.300}" } }, + "border-primary": { "$type": "utility", "$value": "{color.alpha-black.300}", "$extensions": { "sure.utility": { "prefix": "border" }, "sure.dark": "{color.alpha-white.500}" } }, + "border-secondary": { "$type": "utility", "$value": "{color.alpha-black.200}", "$extensions": { "sure.utility": { "prefix": "border" }, "sure.dark": "{color.alpha-white.400}" } }, "border-divider": { "$type": "utility", "$value": "border-tertiary" }, - "border-subdued": { "$type": "utility", "$value": "{color.alpha-black.50}", "$extensions": { "sure.utility": { "prefix": "border" }, "sure.dark": "{color.alpha-white.100}" } }, + "border-subdued": { "$type": "utility", "$value": "{color.alpha-black.50}", "$extensions": { "sure.utility": { "prefix": "border" }, "sure.dark": "{color.alpha-white.200}" } }, "border-solid": { "$type": "utility", "$value": "{color.black}", "$extensions": { "sure.utility": { "prefix": "border" }, "sure.dark": "{color.white}" } }, - "border-destructive": { "$type": "utility", "$value": "{color.red.500}", "$extensions": { "sure.utility": { "prefix": "border" }, "sure.dark": "{color.red.400}" } }, + "border-destructive": { "$type": "utility", "$value": "{color.red.600}", "$extensions": { "sure.utility": { "prefix": "border" }, "sure.dark": "{color.red.400}" } }, "border-inverse": { "$type": "utility", "$value": "{color.alpha-white.200}", "$extensions": { "sure.utility": { "prefix": "border" }, "sure.dark": "{color.alpha-black.300}" } }, "button-bg-primary": { "$type": "utility", "$value": "{color.gray.900}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.white}" } }, @@ -343,8 +343,8 @@ "button-bg-secondary-strong": { "$type": "utility", "$value": "{color.gray.200}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.gray.700}" } }, "button-bg-secondary-strong-hover": { "$type": "utility", "$value": "{color.gray.300}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.gray.600}" } }, "button-bg-disabled": { "$type": "utility", "$value": "{color.gray.50}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.gray.700}" } }, - "button-bg-destructive": { "$type": "utility", "$value": "{color.red.500}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.red.400}" } }, - "button-bg-destructive-hover": { "$type": "utility", "$value": "{color.red.600}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.red.500}" } }, + "button-bg-destructive": { "$type": "utility", "$value": "{color.red.600}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.red.400}" } }, + "button-bg-destructive-hover": { "$type": "utility", "$value": "{color.red.700}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.red.500}" } }, "button-bg-ghost-hover": { "$type": "utility", "$value": "{color.gray.50}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "bg-gray-800 text-inverse" } }, "button-bg-outline-hover": { "$type": "utility", "$value": "{color.gray.100}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.gray.700}" } }, From 74f811c3347dbdddb1f5492d6a1f684f5d2d1c76 Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Wed, 3 Jun 2026 15:15:49 +0200 Subject: [PATCH 06/20] fix(accounts): honor stored return_to after subtype account creation (#2109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(accounts): honor stored return_to after subtype account creation Closes #1766. The savings-goals empty-state "Add an account" CTA passes ?return_to, which StoreLocation captures into session[:return_to], but account-creation flows didn't always consume it: - AccountableResource#create honored a form-carried return_to but not the session value, so if the param wasn't threaded through the multi-step new-account flow the user still landed on the account page. Added a session[:return_to] fallback (the form param still wins). - PropertiesController is a 3-step wizard (create → balances → address) that never threaded return_to as a form param, and its final redirect went straight to account_path. It now honors session[:return_to] on completion. Rails blocks external-host redirects, so return_to can't open-redirect. valuations#create uses redirect_back_or_to (referer-based) — different flow, left as-is. Tests: depository create prefers the form return_to and falls back to the session value; property wizard completion honors the stored return_to. * fix(accounts): block open-redirect via return_to; consume session value Two AI-review findings on #2109: - Open-redirect (codex): the property wizard's turbo_stream completion uses stream_redirect_to, which the client resolves with Turbo.visit — that full-navigates cross-origin, bypassing Rails' redirect host-guard. A crafted ?return_to=https://evil could walk the user off-site. Filter return_to at the StoreLocation choke point (store time) to internal absolute paths only, and sanitize the separate form-param channel, so an unsafe value can't reach redirect_to / stream_redirect_to. - Stale session (coderabbit): session[:return_to] was read but never consumed. Consume it with delete at redirect time so it can't leak into a later flow. Adds guard tests (external return_to falls back to the account page). * fix(security): guard safe_return_to against non-String return_to A crafted `?return_to[]=foo` makes params[:return_to] an Array, and Array#match? doesn't exist, so safe_return_to raised NoMethodError before the open-redirect hardening could reject it. Add an is_a?(String) check as the first gate. Other CodeRabbit/Codex return_to findings on this PR were already addressed (consume-side re-validation + session.delete). --- .../concerns/accountable_resource.rb | 9 +- app/controllers/concerns/store_location.rb | 17 +++- app/controllers/properties_controller.rb | 13 ++- .../depositories_controller_test.rb | 31 +++++++ .../controllers/properties_controller_test.rb | 85 +++++++++++++++++++ 5 files changed, 149 insertions(+), 6 deletions(-) diff --git a/app/controllers/concerns/accountable_resource.rb b/app/controllers/concerns/accountable_resource.rb index 479ad9efc..18e38b15e 100644 --- a/app/controllers/concerns/accountable_resource.rb +++ b/app/controllers/concerns/accountable_resource.rb @@ -48,7 +48,14 @@ module AccountableResource @account.lock_saved_attributes! end - redirect_to account_params[:return_to].presence || @account, notice: t("accounts.create.success", type: accountable_type.name.underscore.humanize) + # Prefer the form-carried return_to, then the session value StoreLocation + # captured from `?return_to=` (survives multi-step flows where the param + # isn't threaded), then the account page. The form param is sanitized here + # (the session value is already filtered at store time); the session is + # consumed with delete so a stale value can't leak into a later flow. + return_path = safe_return_to(account_params[:return_to]) || session.delete(:return_to).presence || @account + redirect_to return_path, + notice: t("accounts.create.success", type: accountable_type.name.underscore.humanize) end def update diff --git a/app/controllers/concerns/store_location.rb b/app/controllers/concerns/store_location.rb index e2e8d3181..14f98068d 100644 --- a/app/controllers/concerns/store_location.rb +++ b/app/controllers/concerns/store_location.rb @@ -24,9 +24,20 @@ private end def store_return_to - if params[:return_to].present? - session[:return_to] = params[:return_to] - end + safe = safe_return_to(params[:return_to]) + session[:return_to] = safe if safe + end + + # Only allow internal absolute paths (a single leading "/"). Blocks absolute + # URLs, protocol-relative ("//evil"), and backslash tricks ("/\\evil") so a + # crafted ?return_to= can't open-redirect — including via a custom + # turbo_stream redirect, which Rails' redirect host-guard does NOT cover + # (the client `Turbo.visit`es the target and full-navigates cross-origin). + def safe_return_to(value) + # is_a?(String) first: a crafted `?return_to[]=foo` makes params[:return_to] + # an Array, and Array#match? doesn't exist — without this guard the helper + # raises NoMethodError before the redirect hardening can reject it. + value if value.is_a?(String) && value.present? && value.match?(%r{\A/(?![/\\])}) end def clear_previous_path diff --git a/app/controllers/properties_controller.rb b/app/controllers/properties_controller.rb index 1de7a5f8c..9b00d4cee 100644 --- a/app/controllers/properties_controller.rb +++ b/app/controllers/properties_controller.rb @@ -75,9 +75,18 @@ class PropertiesController < ApplicationController if @account.draft? @account.activate! + # The property setup wizard (create → balances → address) is multi-step, + # so the original `?return_to=` only survives in the session (captured by + # StoreLocation), not as a threaded form param. Honor it on completion so + # flows like the savings-goals "Add an account" CTA land back where they + # started instead of on the account page. Sanitized + consumed: the + # turbo_stream branch below isn't covered by Rails' redirect host-guard, + # so an unsafe value must not reach stream_redirect_to. + return_path = safe_return_to(session.delete(:return_to)) || account_path(@account) + respond_to do |format| - format.html { redirect_to account_path(@account) } - format.turbo_stream { stream_redirect_to account_path(@account) } + format.html { redirect_to return_path } + format.turbo_stream { stream_redirect_to return_path } end else @success_message = "Address updated successfully." diff --git a/test/controllers/depositories_controller_test.rb b/test/controllers/depositories_controller_test.rb index 3c6e443a5..9ce0eb70b 100644 --- a/test/controllers/depositories_controller_test.rb +++ b/test/controllers/depositories_controller_test.rb @@ -7,4 +7,35 @@ class DepositoriesControllerTest < ActionDispatch::IntegrationTest sign_in @user = users(:family_admin) @account = accounts(:depository) end + + test "create falls back to the stored return_to when no form param is present" do + get new_account_path(return_to: transactions_path) # StoreLocation captures it into the session + + assert_difference -> { Account.count } => 1 do + post depositories_path, params: { + account: { name: "Return To Checking", currency: "USD", balance: 100, accountable_type: "Depository" } + } + end + + assert_redirected_to transactions_path + end + + test "create prefers the form return_to over the session value" do + get new_account_path(return_to: transactions_path) # session return_to + + post depositories_path, params: { + account: { name: "Form RT Checking", currency: "USD", balance: 100, accountable_type: "Depository", return_to: budgets_path } + } + + assert_redirected_to budgets_path + end + + test "create ignores an external return_to (open-redirect guard)" do + post depositories_path, params: { + account: { name: "Evil RT Checking", currency: "USD", balance: 100, accountable_type: "Depository", return_to: "https://evil.example/phish" } + } + + created = Account.order(:created_at).last + assert_redirected_to account_path(created) # not the external URL + end end diff --git a/test/controllers/properties_controller_test.rb b/test/controllers/properties_controller_test.rb index ce5a97847..81ecd88f8 100644 --- a/test/controllers/properties_controller_test.rb +++ b/test/controllers/properties_controller_test.rb @@ -188,4 +188,89 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest assert draft_account.active? assert_redirected_to account_path(draft_account) end + + test "address update on draft account honors stored return_to over the account page" do + draft_account = Account.create!( + family: @user.family, + name: "Draft Property RT", + accountable: Property.new, + status: "draft", + balance: 500000, + currency: "USD" + ) + + # The property wizard (create → balances → address) doesn't thread return_to + # as a form param, so StoreLocation's session value is the only carrier. + get new_account_path(return_to: transactions_path) + + patch update_address_property_path(draft_account), params: { + property: { + address_attributes: { + line1: "789 Activate St", + locality: "New York", + region: "NY", + country: "US", + postal_code: "10001" + } + } + } + + draft_account.reload + assert draft_account.active? + assert_redirected_to transactions_path + end + + test "address update ignores an external stored return_to (open-redirect guard)" do + draft_account = Account.create!( + family: @user.family, + name: "Draft Property Evil", + accountable: Property.new, + status: "draft", + balance: 500000, + currency: "USD" + ) + + # A hostile ?return_to is rejected at store time, so the wizard falls back + # to the account page rather than stream-redirecting off-site. + get new_account_path(return_to: "https://evil.example/phish") + + patch update_address_property_path(draft_account), params: { + property: { + address_attributes: { + line1: "1 Safe St", locality: "NYC", region: "NY", country: "US", postal_code: "10001" + } + } + } + + draft_account.reload + assert draft_account.active? + assert_redirected_to account_path(draft_account) + end + + test "address update tolerates a non-String stored return_to without raising" do + draft_account = Account.create!( + family: @user.family, + name: "Draft Property Array", + accountable: Property.new, + status: "draft", + balance: 500000, + currency: "USD" + ) + + # `?return_to[]=foo` makes params[:return_to] an Array; safe_return_to must + # reject it via the is_a?(String) guard instead of raising NoMethodError. + get new_account_path("return_to" => [ "/transactions" ]) + + patch update_address_property_path(draft_account), params: { + property: { + address_attributes: { + line1: "1 Safe St", locality: "NYC", region: "NY", country: "US", postal_code: "10001" + } + } + } + + draft_account.reload + assert draft_account.active? + assert_redirected_to account_path(draft_account) + end end From 76d4c2a2feebd6ab4658517dcbd3bc4bc1722f20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Wed, 3 Jun 2026 17:39:27 +0200 Subject: [PATCH 07/20] Pass `APP_BUNDLE_ID` into iOS archive (#2169) * Pass APP_BUNDLE_ID into iOS archive * Rewrite bundle identifier for override values --- .github/workflows/ios-testflight.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ios-testflight.yml b/.github/workflows/ios-testflight.yml index 13ae2f52c..8c698ab4b 100644 --- a/.github/workflows/ios-testflight.yml +++ b/.github/workflows/ios-testflight.yml @@ -153,7 +153,7 @@ jobs: working-directory: mobile if: ${{ steps.check_prereqs.outputs.enabled == 'true' }} env: - APP_BUNDLE_ID: am.sure.mobile + APP_BUNDLE_ID: ${{ vars.IOS_APP_BUNDLE_ID || 'am.sure.mobile' }} IOS_TEAM_ID: ${{ secrets.IOS_TEAM_ID }} PROFILE_NAME: ${{ secrets.IOS_PROVISIONING_PROFILE_NAME }} IOS_DISTRIBUTION_CERT_NAME: ${{ secrets.IOS_DISTRIBUTION_CERT_NAME }} @@ -173,14 +173,16 @@ jobs: path = Path("ios/Runner.xcodeproj/project.pbxproj") text = path.read_text() + app_bundle_id = os.environ["APP_BUNDLE_ID"] team = os.environ["IOS_TEAM_ID"] profile = os.environ["PROFILE_NAME"] identity = os.environ["IOS_DISTRIBUTION_CERT_NAME"] def patch_block(match): block = match.group(0) - if "PRODUCT_BUNDLE_IDENTIFIER = am.sure.mobile;" not in block: + if "PRODUCT_BUNDLE_IDENTIFIER =" not in block: return block + block = re.sub(r'PRODUCT_BUNDLE_IDENTIFIER = .*?;', f'PRODUCT_BUNDLE_IDENTIFIER = {app_bundle_id};', block) if "CODE_SIGN_STYLE = Manual;" not in block: block = block.replace("CURRENT_PROJECT_VERSION = \"$(FLUTTER_BUILD_NUMBER)\";", "CURRENT_PROJECT_VERSION = \"$(FLUTTER_BUILD_NUMBER)\";\n\t\t\t\tCODE_SIGN_STYLE = Manual;") if '"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Distribution";' not in block: @@ -246,6 +248,7 @@ jobs: -destination 'generic/platform=iOS' \ MARKETING_VERSION="$IOS_VERSION" \ CURRENT_PROJECT_VERSION="$IOS_BUILD_NUMBER" \ + PRODUCT_BUNDLE_IDENTIFIER="$APP_BUNDLE_ID" \ archive mkdir -p "$EXPORT_PATH" From 6e04c6927d1079b58a8e2ad237867154e76124c7 Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Thu, 4 Jun 2026 02:48:44 -0700 Subject: [PATCH 08/20] feat(imports): add SureImport session batches (#1785) * feat(imports): add SureImport session batches Add first-class SureImport sessions for ordered multi-file NDJSON imports. Persist source mappings across chunks, make session/chunk processing idempotent, expose progress readback, and keep existing single-file import behavior compatible. Includes the devcontainer libvips runtime dependency needed by ActiveStorage variant tests. Addresses #1610. Related to #1458. * fix(imports): avoid scanner-like API key test data * test(imports): assert skipped balances are not persisted * fix(imports): harden session publish retries Validate expected import chunk sequences exactly before publish, and restore session state with error details when enqueueing the publish job fails. * fix(imports): close session retry edge cases Backfill expected chunk counts after client-session insert races and enqueue import-session jobs after the status transition commits. Persist a safe enqueue failure body so API readback does not expose raw queue errors. * fix(imports): address session publish review gaps Remove dead transaction external-id assignment, harden session publish retry/sync behavior, align session chunk status docs, and add regression coverage for partial retries and safe enqueue error readback. * fix(imports): include sessions in family reset Clear import sessions through the family reset job so chunk imports and source mappings do not survive a reset. Expose import session and source mapping counts in the reset status response and regenerated OpenAPI schema so polling reflects the full reset surface. * test(imports): cover split import mapping invariants * test(imports): cover session verification invariants * fix(imports): scope SureImport session reimports * Tighten SureImport session batching * fix(imports): export rule source ids for sessions * test(imports): stabilize rule id export assertion * test(imports): restore reset status session fixture --- .rubocop.yml | 2 +- .../api/v1/import_sessions_controller.rb | 195 +++++ app/jobs/import_session_job.rb | 12 + app/models/family.rb | 2 + app/models/family/data_exporter.rb | 1 + app/models/family/data_importer.rb | 513 +++++++++-- app/models/family/financial_data_reset.rb | 7 + app/models/import.rb | 28 + app/models/import_session.rb | 425 +++++++++ app/models/import_source_mapping.rb | 41 + app/models/sure_import.rb | 20 +- config/routes.rb | 4 + .../20260513013000_create_import_sessions.rb | 78 ++ db/schema.rb | 63 ++ docs/api/openapi.yaml | 377 ++++++++ spec/requests/api/v1/import_sessions_spec.rb | 430 ++++++++++ spec/swagger_helper.rb | 62 ++ .../api/v1/import_sessions_controller_test.rb | 308 +++++++ .../api/v1/users_controller_test.rb | 16 + test/jobs/family_reset_job_test.rb | 47 + test/models/family/data_exporter_test.rb | 5 +- test/models/family/data_importer_test.rb | 190 +++- test/models/import_session_test.rb | 809 ++++++++++++++++++ 23 files changed, 3542 insertions(+), 93 deletions(-) create mode 100644 app/controllers/api/v1/import_sessions_controller.rb create mode 100644 app/jobs/import_session_job.rb create mode 100644 app/models/import_session.rb create mode 100644 app/models/import_source_mapping.rb create mode 100644 db/migrate/20260513013000_create_import_sessions.rb create mode 100644 spec/requests/api/v1/import_sessions_spec.rb create mode 100644 test/controllers/api/v1/import_sessions_controller_test.rb create mode 100644 test/models/import_session_test.rb diff --git a/.rubocop.yml b/.rubocop.yml index 33542a43c..d313b5ab5 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,6 +1,6 @@ inherit_gem: rubocop-rails-omakase: rubocop.yml - + Layout/IndentationWidth: Enabled: true diff --git a/app/controllers/api/v1/import_sessions_controller.rb b/app/controllers/api/v1/import_sessions_controller.rb new file mode 100644 index 000000000..f749124d7 --- /dev/null +++ b/app/controllers/api/v1/import_sessions_controller.rb @@ -0,0 +1,195 @@ +# frozen_string_literal: true + +class Api::V1::ImportSessionsController < Api::V1::BaseController + before_action :ensure_read_scope, only: [ :show ] + before_action :ensure_write_scope, only: [ :create, :create_chunk, :publish ] + before_action :set_import_session, only: [ :show, :create_chunk, :publish ] + + def create + @import_session = ImportSession.create_or_find_for!( + family: Current.family, + import_type: params[:type].to_s, + client_session_id: params[:client_session_id].presence, + expected_chunks: expected_chunks_param + ) + + render_import_session(status: :created) + rescue ImportSession::ConflictError => e + render_import_session_conflict(e.message) + rescue ActiveRecord::RecordInvalid => e + render_error( + "validation_failed", + "Import session could not be created", + :unprocessable_entity, + errors: e.record.errors.full_messages + ) + end + + def show + render_import_session + end + + def create_chunk + content, filename, content_type = sure_import_upload_attributes + return unless content + + @import_session.attach_chunk!( + sequence: sequence_param, + client_chunk_id: params[:client_chunk_id].presence, + content: content, + filename: filename, + content_type: content_type + ) + + @import_session.reload + render_import_session(status: :created) + rescue ImportSession::ConflictError => e + render_import_session_conflict(e.message) + rescue ActiveRecord::RecordInvalid => e + render_error( + "validation_failed", + "Import chunk could not be created", + :unprocessable_entity, + errors: e.record.errors.full_messages + ) + end + + def publish + @import_session.publish_later + @import_session.reload + render_import_session(status: :accepted) + rescue Import::MaxRowCountExceededError + render_error("max_row_count_exceeded", "Import session has too many rows to publish.", :unprocessable_entity) + rescue ImportSession::EnqueueError + render_error("import_enqueue_failed", "Import session could not be queued.", :service_unavailable) + rescue ImportSession::ConflictError => e + render_import_session_conflict(e.message) + end + + private + def set_import_session + @import_session = Current.family.import_sessions.find(params[:id]) + end + + def ensure_read_scope + authorize_scope!(:read) + end + + def ensure_write_scope + authorize_scope!(:write) + end + + def expected_chunks_param + return if params[:expected_chunks].blank? + + params[:expected_chunks] + end + + def sequence_param + raise ActionController::ParameterMissing.new(:sequence) if params[:sequence].blank? + + params[:sequence] + end + + def sure_import_upload_attributes + if params[:file].present? + sure_import_file_upload_attributes(params[:file]) + elsif params[:raw_file_content].present? + sure_import_raw_content_attributes(params[:raw_file_content].to_s) + else + render_error("missing_content", "Provide a Sure NDJSON file or raw_file_content.", :unprocessable_entity) + nil + end + end + + def sure_import_file_upload_attributes(file) + if file.size > SureImport.max_ndjson_size + render_error( + "file_too_large", + "File is too large. Maximum size is #{SureImport.max_ndjson_size / 1.megabyte}MB.", + :unprocessable_entity + ) + return + end + + extension = File.extname(file.original_filename.to_s).downcase + unless SureImport::ALLOWED_NDJSON_CONTENT_TYPES.include?(file.content_type) || extension.in?(%w[.ndjson .json]) + render_error("invalid_file_type", "Invalid file type. Please upload a Sure NDJSON file.", :unprocessable_entity) + return + end + + sure_import_validated_attributes( + content: file.read, + filename: file.original_filename.presence || "sure-import.ndjson", + content_type: file.content_type.presence || "application/x-ndjson" + ) + end + + def sure_import_raw_content_attributes(content) + if content.bytesize > SureImport.max_ndjson_size + render_error( + "content_too_large", + "Content is too large. Maximum size is #{SureImport.max_ndjson_size / 1.megabyte}MB.", + :unprocessable_entity + ) + return + end + + sure_import_validated_attributes( + content: content, + filename: "sure-import.ndjson", + content_type: "application/x-ndjson" + ) + end + + def sure_import_validated_attributes(content:, filename:, content_type:) + unless SureImport.valid_ndjson_first_line?(content) + render_error("invalid_ndjson", "Invalid Sure NDJSON content.", :unprocessable_entity) + return + end + + [ content, filename, content_type ] + end + + def render_import_session_conflict(message) + render_error("import_session_conflict", message, :conflict) + end + + def render_import_session(status: :ok) + chunks = @import_session.imports.ordered_by_sequence.map do |import| + { + id: import.id, + sequence: import.sequence, + client_chunk_id: import.client_chunk_id, + status: import.status, + rows_count: import.rows_count, + summary: import.summary || {}, + error: import.error_details.presence, + created_at: import.created_at, + updated_at: import.updated_at + } + end + + render json: { + data: { + id: @import_session.id, + type: @import_session.import_type, + status: @import_session.status, + client_session_id: @import_session.client_session_id, + expected_chunks: @import_session.expected_chunks, + chunks_count: chunks.size, + summary: @import_session.summary || {}, + error: @import_session.error_details.presence, + created_at: @import_session.created_at, + updated_at: @import_session.updated_at, + chunks: chunks + } + }, status: status + end + + def render_error(error, message, status, errors: nil) + payload = { error: error, message: message } + payload[:errors] = errors if errors + render json: payload, status: status + end +end diff --git a/app/jobs/import_session_job.rb b/app/jobs/import_session_job.rb new file mode 100644 index 000000000..de7f0e377 --- /dev/null +++ b/app/jobs/import_session_job.rb @@ -0,0 +1,12 @@ +class ImportSessionJob < ApplicationJob + queue_as :high_priority + + def perform(import_session) + raise ArgumentError, "ImportSessionJob requires an import_session" if import_session.nil? + + Rails.logger.info("ImportSessionJob started import_session_id=#{import_session.id}") + import_session.publish + import_session.reload + Rails.logger.info("ImportSessionJob finished import_session_id=#{import_session.id} status=#{import_session.status}") + end +end diff --git a/app/models/family.rb b/app/models/family.rb index d4c6cf1e4..c5f8f2252 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -27,6 +27,8 @@ class Family < ApplicationRecord has_many :invitations, dependent: :destroy has_many :imports, dependent: :destroy + has_many :import_sessions, dependent: :destroy + has_many :import_source_mappings, dependent: :destroy has_many :family_exports, dependent: :destroy has_many :account_statements, dependent: :destroy diff --git a/app/models/family/data_exporter.rb b/app/models/family/data_exporter.rb index 404218056..20e08a70d 100644 --- a/app/models/family/data_exporter.rb +++ b/app/models/family/data_exporter.rb @@ -545,6 +545,7 @@ class Family::DataExporter def serialize_rule_for_export(rule) { + id: rule.id, name: rule.name, resource_type: rule.resource_type, active: rule.active, diff --git a/app/models/family/data_importer.rb b/app/models/family/data_importer.rb index 4de55a9bc..7a68419c5 100644 --- a/app/models/family/data_importer.rb +++ b/app/models/family/data_importer.rb @@ -1,6 +1,36 @@ require "set" class Family::DataImporter + MissingReferenceError = Class.new(StandardError) do + attr_reader :code, :details + + def initialize(record_type:, source_type:, source_id:) + @code = "missing_source_reference" + @details = { + record_type: record_type, + source_type: source_type, + source_id: source_id + } + + super("#{record_type} references missing #{source_type} source id #{source_id}") + end + end + + InvalidRecordError = Class.new(StandardError) do + attr_reader :code, :details + + def initialize(record_type:, field:, value:) + @code = "invalid_import_record" + @details = { + record_type: record_type, + field: field, + value: value + } + + super("#{record_type} has invalid #{field}: #{value.inspect}") + end + end + SUPPORTED_TYPES = %w[Account Balance Category Tag Merchant RecurringTransaction Transaction Transfer RejectedTransfer Trade Holding Valuation Budget BudgetCategory Rule].freeze ACCOUNTABLE_TYPE_CLASSES = { "Depository" => Depository, "Investment" => Investment, "Crypto" => Crypto, @@ -12,9 +42,41 @@ class Family::DataImporter ACCOUNTABLE_TYPE_CLASSES[type.to_s] end - def initialize(family, ndjson_content) + MAPPING_TYPES = { + accounts: "Account", + categories: "Category", + tags: "Tag", + merchants: "Merchant", + recurring_transactions: "RecurringTransaction", + transactions: "Transaction", + budgets: "Budget", + securities: "Security", + rules: "Rule" + }.freeze + SUMMARY_KEYS = { + "Account" => "accounts", + "Balance" => "balances", + "Category" => "categories", + "Tag" => "tags", + "Merchant" => "merchants", + "RecurringTransaction" => "recurring_transactions", + "Transaction" => "transactions", + "Transfer" => "transfers", + "RejectedTransfer" => "rejected_transfers", + "Trade" => "trades", + "Holding" => "holdings", + "Valuation" => "valuations", + "Budget" => "budgets", + "BudgetCategory" => "budget_categories", + "Rule" => "rules" + }.freeze + + def initialize(family, ndjson_content, import_session: nil, import: nil) @family = family @ndjson_content = ndjson_content + @import_session = import_session + @import = import + @strict_references = import_session.present? @id_mappings = { accounts: {}, categories: {}, @@ -23,11 +85,13 @@ class Family::DataImporter recurring_transactions: {}, transactions: {}, budgets: {}, - securities: {} + securities: {}, + rules: {} } @security_cache = {} @created_accounts = [] @created_entries = [] + @summary = Hash.new { |hash, key| hash[key] = empty_summary_bucket } end def import! @@ -54,7 +118,7 @@ class Family::DataImporter import_rules(records["Rule"] || []) end - { accounts: @created_accounts, entries: @created_entries } + { accounts: @created_accounts, entries: @created_entries, summary: compact_summary } end private @@ -79,6 +143,128 @@ class Family::DataImporter records end + def empty_summary_bucket + { "created" => 0, "updated" => 0, "skipped" => 0, "failed" => 0 } + end + + def compact_summary + @summary.select { |_entity_type, counts| counts.values.any?(&:positive?) } + end + + def increment_summary(record_type, status) + @summary[SUMMARY_KEYS.fetch(record_type)].tap do |counts| + counts[status.to_s] = counts.fetch(status.to_s, 0) + 1 + end + end + + def map_source!(mapping_key, source_id, target) + return if source_id.blank? || target.blank? + + @id_mappings[mapping_key][source_id] = target.id + return unless @import_session + + source_type = MAPPING_TYPES.fetch(mapping_key) + mapping = @import_session.source_mappings.find_or_initialize_by( + family: @family, + source_type: source_type, + source_id: source_id + ) + mapping.target = target + mapping.save! + end + + def mapped_id(mapping_key, old_id, record_type:, required: true) + if old_id.blank? + missing_reference(record_type, mapping_key, "(blank)") if required + return + end + + return @id_mappings[mapping_key][old_id] if @id_mappings[mapping_key].key?(old_id) + + source_type = MAPPING_TYPES.fetch(mapping_key) + mapping = @import_session&.source_mappings&.find_by( + family: @family, + source_type: source_type, + source_id: old_id + ) + + if mapping + @id_mappings[mapping_key][old_id] = mapping.target_id + return mapping.target_id + end + + if required && @strict_references + raise MissingReferenceError.new( + record_type: record_type, + source_type: source_type, + source_id: old_id + ) + end + + nil + end + + def mapped_record(mapping_key, old_id, scope, record_type:) + target_id = mapped_id(mapping_key, old_id, record_type: record_type, required: false) + return if target_id.blank? + + scope.find_by(id: target_id) + end + + def missing_reference(record_type, mapping_key, old_id) + if @strict_references + increment_summary(record_type, :failed) + raise MissingReferenceError.new( + record_type: record_type, + source_type: MAPPING_TYPES.fetch(mapping_key), + source_id: old_id + ) + end + + increment_summary(record_type, :skipped) + nil + end + + def require_source_id!(record_type, source_id) + return if source_id.present? || !@strict_references + + increment_summary(record_type, :failed) + raise MissingReferenceError.new( + record_type: record_type, + source_type: record_type, + source_id: "(blank)" + ) + end + + def invalid_record!(record_type, field, value) + if @strict_references + increment_summary(record_type, :failed) + raise InvalidRecordError.new(record_type: record_type, field: field, value: value) + end + + increment_summary(record_type, :skipped) + nil + end + + def session_entry_source + return unless @import_session + + "sure_import_session:#{@import_session.id}" + end + + def session_entry_external_id(record_type, source_id) + return if @import_session.blank? || source_id.blank? + + "#{record_type}:#{source_id}" + end + + def session_imported_entry(account, record_type, source_id) + external_id = session_entry_external_id(record_type, source_id) + return if external_id.blank? + + account.entries.find_by(source: session_entry_source, external_id: external_id) + end + def import_accounts(records) records.each do |record| data = record["data"] @@ -86,26 +272,41 @@ class Family::DataImporter accountable_data = data["accountable"] || {} accountable_type = data["accountable_type"] + require_source_id!("Account", old_id) + accountable_class = self.class.accountable_class_for(accountable_type) - next unless accountable_class - accountable = accountable_class.new - accountable.subtype = accountable_data["subtype"] if accountable.respond_to?(:subtype=) && accountable_data["subtype"] - - # Copy any other accountable attributes - safe_accountable_attrs = %w[subtype locked_attributes] - safe_accountable_attrs.each do |attr| - if accountable.respond_to?("#{attr}=") && accountable_data[attr].present? - accountable.send("#{attr}=", accountable_data[attr]) - end + unless accountable_class + invalid_record!("Account", "accountable_type", accountable_type) + next end - account = @family.accounts.build( + account = mapped_record(:accounts, old_id, @family.accounts, record_type: "Account") + created = account.blank? + + if account + accountable = account.accountable + else + # Build accountable + accountable = accountable_class.new + accountable.subtype = accountable_data["subtype"] if accountable.respond_to?(:subtype=) && accountable_data["subtype"] + + # Copy any other accountable attributes + safe_accountable_attrs = %w[subtype locked_attributes] + safe_accountable_attrs.each do |attr| + if accountable.respond_to?("#{attr}=") && accountable_data[attr].present? + accountable.send("#{attr}=", accountable_data[attr]) + end + end + + account = @family.accounts.build(accountable: accountable) + end + + account.assign_attributes( name: data["name"], balance: data["balance"].to_d, cash_balance: data["cash_balance"]&.to_d || data["balance"].to_d, currency: data["currency"] || @family.currency, - accountable: accountable, subtype: data["subtype"], institution_name: data["institution_name"], institution_domain: data["institution_domain"], @@ -118,7 +319,7 @@ class Family::DataImporter # Set opening balance if we have a historical balance and the import # does not provide either an explicit opening-anchor valuation or an # authoritative balance-history stream for this account. - if data["balance"].present? && !skip_opening_balance_import?(old_id, data) + if created && data["balance"].present? && !skip_opening_balance_import?(old_id, data) manager = Account::OpeningBalanceManager.new(account) result = manager.set_opening_balance( balance: data["balance"].to_d, @@ -127,8 +328,9 @@ class Family::DataImporter log_failed_opening_balance_import(account, old_id, result) unless result.success? end - @id_mappings[:accounts][old_id] = account.id - @created_accounts << account + map_source!(:accounts, old_id, account) + @created_accounts << account if created + increment_summary("Account", created ? :created : :updated) end end @@ -139,16 +341,23 @@ class Family::DataImporter def import_balances(records) records.each do |record| data = record["data"] || {} - new_account_id = @id_mappings[:accounts][data["account_id"]] + new_account_id = mapped_id(:accounts, data["account_id"], record_type: "Balance") balance_date = parse_import_date(data["date"]) - next if new_account_id.blank? || balance_date.blank? || data["balance"].blank? + next if new_account_id.blank? + + if balance_date.blank? || data["balance"].blank? + increment_summary("Balance", :skipped) + next + end account = @family.accounts.find(new_account_id) currency = data["currency"].presence || account.currency balance = account.balances.find_or_initialize_by(date: balance_date, currency: currency) + created = balance.new_record? balance.assign_attributes(imported_balance_attributes(data)) balance.save! + increment_summary("Balance", created ? :created : :updated) end end @@ -188,24 +397,30 @@ class Family::DataImporter old_id = data["id"] parent_id = data["parent_id"] + require_source_id!("Category", old_id) + # Store parent relationship for second pass parent_mappings[old_id] = parent_id if parent_id.present? - category = @family.categories.build( + category = mapped_record(:categories, old_id, @family.categories, record_type: "Category") + created = category.blank? + category ||= @family.categories.build + + category.assign_attributes( name: data["name"], color: data["color"] || Category::UNCATEGORIZED_COLOR, classification_unused: data["classification_unused"] || data["classification"] || "expense", lucide_icon: data["lucide_icon"] || "shapes" ) category.save! - - @id_mappings[:categories][old_id] = category.id + map_source!(:categories, old_id, category) + increment_summary("Category", created ? :created : :updated) end # Second pass: establish parent relationships parent_mappings.each do |old_id, old_parent_id| - new_id = @id_mappings[:categories][old_id] - new_parent_id = @id_mappings[:categories][old_parent_id] + new_id = mapped_id(:categories, old_id, record_type: "Category") + new_parent_id = mapped_id(:categories, old_parent_id, record_type: "Category") next unless new_id && new_parent_id @@ -219,13 +434,22 @@ class Family::DataImporter data = record["data"] old_id = data["id"] - tag = @family.tags.build( + require_source_id!("Tag", old_id) + + tag = mapped_record(:tags, old_id, @family.tags, record_type: "Tag") + created = tag.blank? + tag ||= @family.tags.build + color = data["color"] || tag.color + # Keep replayed session imports deterministic when the source omits a color. + color ||= Tag::COLORS.first if created + + tag.assign_attributes( name: data["name"], - color: data["color"] || Tag::COLORS.sample + color: color ) tag.save! - - @id_mappings[:tags][old_id] = tag.id + map_source!(:tags, old_id, tag) + increment_summary("Tag", created ? :created : :updated) end end @@ -234,14 +458,20 @@ class Family::DataImporter data = record["data"] old_id = data["id"] - merchant = @family.merchants.build( + require_source_id!("Merchant", old_id) + + merchant = mapped_record(:merchants, old_id, @family.merchants, record_type: "Merchant") + created = merchant.blank? + merchant ||= @family.merchants.build + + merchant.assign_attributes( name: data["name"], color: data["color"], logo_url: data["logo_url"] ) merchant.save! - - @id_mappings[:merchants][old_id] = merchant.id + map_source!(:merchants, old_id, merchant) + increment_summary("Merchant", created ? :created : :updated) end end @@ -250,10 +480,20 @@ class Family::DataImporter data = record["data"] old_id = data["id"] - new_account_id = remap_optional_id(:accounts, data["account_id"]) + require_source_id!("RecurringTransaction", old_id) + + recurring_transaction = mapped_record( + :recurring_transactions, + old_id, + @family.recurring_transactions, + record_type: "RecurringTransaction" + ) + created = recurring_transaction.blank? + + new_account_id = remap_optional_id(:accounts, data["account_id"], record_type: "RecurringTransaction") next if data["account_id"].present? && new_account_id.blank? - new_merchant_id = remap_optional_id(:merchants, data["merchant_id"]) + new_merchant_id = remap_optional_id(:merchants, data["merchant_id"], record_type: "RecurringTransaction") next if data["merchant_id"].present? && new_merchant_id.blank? expected_day_of_month = recurring_expected_day_for(data["expected_day_of_month"]) @@ -262,7 +502,8 @@ class Family::DataImporter next_expected_date = parse_import_date(data["next_expected_date"]) next unless last_occurrence_date && next_expected_date - recurring_transaction = @family.recurring_transactions.build( + recurring_transaction ||= @family.recurring_transactions.build + recurring_transaction.assign_attributes( account_id: new_account_id, merchant_id: new_merchant_id, amount: data["amount"].to_d, @@ -280,14 +521,15 @@ class Family::DataImporter ) recurring_transaction.save! - @id_mappings[:recurring_transactions][old_id] = recurring_transaction.id + map_source!(:recurring_transactions, old_id, recurring_transaction) + increment_summary("RecurringTransaction", created ? :created : :updated) end end - def remap_optional_id(mapping_key, old_id) + def remap_optional_id(mapping_key, old_id, record_type:) return if old_id.blank? - @id_mappings[mapping_key][old_id] + mapped_id(mapping_key, old_id, record_type: record_type) end def recurring_transaction_status_for(status) @@ -312,8 +554,10 @@ class Family::DataImporter data = record["data"] old_id = data["id"] + require_source_id!("Transaction", old_id) + # Map account ID - new_account_id = @id_mappings[:accounts][data["account_id"]] + new_account_id = mapped_id(:accounts, data["account_id"], record_type: "Transaction") next unless new_account_id account = @family.accounts.find(new_account_id) @@ -321,55 +565,69 @@ class Family::DataImporter # Map category ID (optional) new_category_id = nil if data["category_id"].present? - new_category_id = @id_mappings[:categories][data["category_id"]] + new_category_id = mapped_id(:categories, data["category_id"], record_type: "Transaction") end # Map merchant ID (optional) new_merchant_id = nil if data["merchant_id"].present? - new_merchant_id = @id_mappings[:merchants][data["merchant_id"]] + new_merchant_id = mapped_id(:merchants, data["merchant_id"], record_type: "Transaction") end # Map tag IDs (optional) - new_tag_ids = mapped_tag_ids(data["tag_ids"]) + new_tag_ids = mapped_tag_ids(data["tag_ids"], record_type: "Transaction") - transaction = Transaction.new( + entry = session_imported_entry(account, "Transaction", old_id) + transaction = entry&.entryable if entry&.entryable.is_a?(Transaction) + created = transaction.blank? + + transaction ||= Transaction.new + transaction.assign_attributes( category_id: new_category_id, merchant_id: new_merchant_id, kind: data["kind"] || "standard" ) - entry = Entry.new( + entry ||= Entry.new(entryable: transaction) + entry.assign_attributes( account: account, date: Date.parse(data["date"].to_s), amount: data["amount"].to_d, name: data["name"] || "Imported transaction", currency: data["currency"] || account.currency, notes: data["notes"], - excluded: data["excluded"] || false, - entryable: transaction + excluded: data["excluded"] || false ) + if @import_session + entry.external_id = session_entry_external_id("Transaction", old_id) + entry.source = session_entry_source + end entry.save! - @id_mappings[:transactions][old_id] = transaction.id + map_source!(:transactions, old_id, transaction) split_rows = importable_split_rows(data) if split_rows.any? - @created_entries << entry + @created_entries << entry if created import_split_lines!(entry, split_rows, fallback_tag_ids: new_tag_ids) else + transaction.taggings.destroy_all unless created new_tag_ids.each do |tag_id| transaction.taggings.create!(tag_id: tag_id) end - @created_entries << entry + @created_entries << entry if created end + + increment_summary("Transaction", created ? :created : :updated) end end - def mapped_tag_ids(old_tag_ids) - Array(old_tag_ids).map { |old_tag_id| @id_mappings[:tags][old_tag_id] }.compact + def mapped_tag_ids(old_tag_ids, record_type:) + Array(old_tag_ids).map do |old_tag_id| + mapped_id(:tags, old_tag_id, record_type: record_type) + end.compact end def importable_split_rows(data) @@ -380,8 +638,8 @@ class Family::DataImporter amount = row["amount"] || row["amount_money"] || row["amount_decimal"] next if amount.blank? - category_id = remap_optional_id(:categories, row["category_id"]) - merchant_id = remap_optional_id(:merchants, row["merchant_id"]) + category_id = remap_optional_id(:categories, row["category_id"], record_type: "Transaction") + merchant_id = remap_optional_id(:merchants, row["merchant_id"], record_type: "Transaction") { old_id: row["id"], @@ -392,7 +650,7 @@ class Family::DataImporter merchant_id_provided: row.key?("merchant_id"), notes: row["notes"], excluded: boolean_import_value(row, "excluded", default: false), - tag_ids: mapped_tag_ids(row["tag_ids"]), + tag_ids: mapped_tag_ids(row["tag_ids"], record_type: "Transaction"), tag_ids_provided: row.key?("tag_ids"), kind: row["kind"] } @@ -424,7 +682,7 @@ class Family::DataImporter transaction.taggings.create!(tag_id: tag_id) end - @id_mappings[:transactions][row[:old_id]] = transaction.id if row[:old_id].present? + map_source!(:transactions, row[:old_id], transaction) if row[:old_id].present? @created_entries << child_entry end end @@ -432,31 +690,60 @@ class Family::DataImporter def import_transfers(records) records.each do |record| data = record["data"] - inflow_transaction_id = @id_mappings[:transactions][data["inflow_transaction_id"]] - outflow_transaction_id = @id_mappings[:transactions][data["outflow_transaction_id"]] + inflow_transaction_id = mapped_id(:transactions, data["inflow_transaction_id"], record_type: "Transfer") + outflow_transaction_id = mapped_id(:transactions, data["outflow_transaction_id"], record_type: "Transfer") next unless inflow_transaction_id && outflow_transaction_id - Transfer.find_or_create_by!( + transfer = Transfer.find_or_create_by!( inflow_transaction_id: inflow_transaction_id, outflow_transaction_id: outflow_transaction_id ) do |transfer| transfer.status = transfer_status_for(data["status"]) transfer.notes = data["notes"] end + apply_transfer_transaction_kinds!(transfer) + increment_summary("Transfer", transfer.previously_new_record? ? :created : :updated) end end + def apply_transfer_transaction_kinds!(transfer) + destination_account = transfer.inflow_transaction.entry.account + outflow_kind = imported_transfer_outflow_kind(transfer) + outflow_attrs = { kind: outflow_kind } + if outflow_kind == "investment_contribution" && transfer.outflow_transaction.category_id.blank? + outflow_attrs[:category] = destination_account.family.investment_contributions_category + end + + transfer.outflow_transaction.update!(outflow_attrs) + transfer.inflow_transaction.update!(kind: "funds_movement") + end + + def imported_transfer_outflow_kind(transfer) + source_account = transfer.outflow_transaction.entry.account + destination_account = transfer.inflow_transaction.entry.account + return "loan_payment" if destination_account.loan? + return "cc_payment" if destination_account.liability? + return "investment_contribution" if investment_account?(destination_account) && !investment_account?(source_account) + + "funds_movement" + end + + def investment_account?(account) + account.investment? || account.crypto? + end + def import_rejected_transfers(records) records.each do |record| data = record["data"] - inflow_transaction_id = @id_mappings[:transactions][data["inflow_transaction_id"]] - outflow_transaction_id = @id_mappings[:transactions][data["outflow_transaction_id"]] + inflow_transaction_id = mapped_id(:transactions, data["inflow_transaction_id"], record_type: "RejectedTransfer") + outflow_transaction_id = mapped_id(:transactions, data["outflow_transaction_id"], record_type: "RejectedTransfer") next unless inflow_transaction_id && outflow_transaction_id - RejectedTransfer.find_or_create_by!( + rejected_transfer = RejectedTransfer.find_or_create_by!( inflow_transaction_id: inflow_transaction_id, outflow_transaction_id: outflow_transaction_id ) + increment_summary("RejectedTransfer", rejected_transfer.previously_new_record? ? :created : :updated) end end @@ -471,9 +758,12 @@ class Family::DataImporter def import_trades(records) records.each do |record| data = record["data"] + old_id = data["id"] + + require_source_id!("Trade", old_id) # Map account ID - new_account_id = @id_mappings[:accounts][data["account_id"]] + new_account_id = mapped_id(:accounts, data["account_id"], record_type: "Trade") next unless new_account_id account = @family.accounts.find(new_account_id) @@ -490,34 +780,47 @@ class Family::DataImporter exchange_operating_mic: data["exchange_operating_mic"] ) - trade = Trade.new( + entry = session_imported_entry(account, "Trade", old_id) + trade = entry&.entryable if entry&.entryable.is_a?(Trade) + created = trade.blank? + + trade ||= Trade.new + trade.assign_attributes( security: security, qty: data["qty"].to_d, price: data["price"].to_d, currency: data["currency"] || account.currency ) - entry = Entry.new( + entry ||= Entry.new(entryable: trade) + entry.assign_attributes( account: account, date: Date.parse(data["date"].to_s), amount: data["amount"].to_d, name: "#{data["qty"].to_d >= 0 ? 'Buy' : 'Sell'} #{ticker}", - currency: data["currency"] || account.currency, - entryable: trade + currency: data["currency"] || account.currency ) + if @import_session + entry.external_id = session_entry_external_id("Trade", old_id) + entry.source = session_entry_source + end entry.save! - @created_entries << entry + @created_entries << entry if created + increment_summary("Trade", created ? :created : :updated) end end def import_holdings(records) - accounts_by_id = @family.accounts.where(id: records.filter_map { |record| @id_mappings[:accounts][record.dig("data", "account_id")] }).index_by(&:id) + account_ids = records.filter_map do |record| + mapped_id(:accounts, record.dig("data", "account_id"), record_type: "Holding", required: false) + end + accounts_by_id = @family.accounts.where(id: account_ids).index_by(&:id) records.each do |record| data = record["data"] - new_account_id = @id_mappings[:accounts][data["account_id"]] + new_account_id = mapped_id(:accounts, data["account_id"], record_type: "Holding") next unless new_account_id account = accounts_by_id[new_account_id] @@ -552,33 +855,46 @@ class Family::DataImporter security_locked: truthy?(data["security_locked"]) || false } - upsert_imported_holding!(account, security, holding_date, holding_currency, holding_attributes) + created = upsert_imported_holding!(account, security, holding_date, holding_currency, holding_attributes) + increment_summary("Holding", created ? :created : :updated) end end def import_valuations(records) records.each do |record| data = record["data"] + old_id = data["id"] + + require_source_id!("Valuation", old_id) # Map account ID - new_account_id = @id_mappings[:accounts][data["account_id"]] + new_account_id = mapped_id(:accounts, data["account_id"], record_type: "Valuation") next unless new_account_id account = @family.accounts.find(new_account_id) - valuation = Valuation.new(kind: valuation_kind_for(data["kind"])) + entry = session_imported_entry(account, "Valuation", old_id) + valuation = entry&.entryable if entry&.entryable.is_a?(Valuation) + created = valuation.blank? + valuation ||= Valuation.new + valuation.kind = valuation_kind_for(data["kind"]) - entry = Entry.new( + entry ||= Entry.new(entryable: valuation) + entry.assign_attributes( account: account, date: Date.parse(data["date"].to_s), amount: data["amount"].to_d, name: data["name"] || "Valuation", - currency: data["currency"] || account.currency, - entryable: valuation + currency: data["currency"] || account.currency ) + if @import_session + entry.external_id = session_entry_external_id("Valuation", old_id) + entry.source = session_entry_source + end entry.save! - @created_entries << entry + @created_entries << entry if created + increment_summary("Valuation", created ? :created : :updated) end end @@ -650,7 +966,13 @@ class Family::DataImporter data = record["data"] old_id = data["id"] - budget = @family.budgets.build( + require_source_id!("Budget", old_id) + + budget = mapped_record(:budgets, old_id, @family.budgets, record_type: "Budget") + created = budget.blank? + budget ||= @family.budgets.build + + budget.assign_attributes( start_date: Date.parse(data["start_date"].to_s), end_date: Date.parse(data["end_date"].to_s), budgeted_spending: data["budgeted_spending"]&.to_d, @@ -659,7 +981,8 @@ class Family::DataImporter ) budget.save! - @id_mappings[:budgets][old_id] = budget.id + map_source!(:budgets, old_id, budget) + increment_summary("Budget", created ? :created : :updated) end end @@ -668,36 +991,49 @@ class Family::DataImporter data = record["data"] # Map budget ID - new_budget_id = @id_mappings[:budgets][data["budget_id"]] + new_budget_id = mapped_id(:budgets, data["budget_id"], record_type: "BudgetCategory") next unless new_budget_id # Map category ID - new_category_id = @id_mappings[:categories][data["category_id"]] + new_category_id = mapped_id(:categories, data["category_id"], record_type: "BudgetCategory") next unless new_category_id budget = @family.budgets.find(new_budget_id) - budget_category = budget.budget_categories.build( + budget_category = budget.budget_categories.find_or_initialize_by(category_id: new_category_id) + created = budget_category.new_record? + budget_category.assign_attributes( category_id: new_category_id, budgeted_spending: data["budgeted_spending"].to_d, currency: data["currency"] || budget.currency ) budget_category.save! + increment_summary("BudgetCategory", created ? :created : :updated) end end def import_rules(records) records.each do |record| data = record["data"] + old_id = data["id"] - rule = @family.rules.build( + require_source_id!("Rule", old_id) + + rule = mapped_record(:rules, old_id, @family.rules, record_type: "Rule") + created = rule.blank? + rule ||= @family.rules.build + + rule.assign_attributes( name: data["name"], resource_type: data["resource_type"] || "transaction", active: data["active"] || false, effective_date: data["effective_date"].present? ? Date.parse(data["effective_date"].to_s) : nil ) + rule.conditions.destroy_all unless created + rule.actions.destroy_all unless created + # Build conditions (data["conditions"] || []).each do |condition_data| build_rule_condition(rule, condition_data) @@ -709,6 +1045,8 @@ class Family::DataImporter end rule.save! + map_source!(:rules, old_id, rule) + increment_summary("Rule", created ? :created : :updated) end end @@ -845,8 +1183,9 @@ class Family::DataImporter return security end - if old_security_id.present? && @id_mappings[:securities][old_security_id] - security = Security.find(@id_mappings[:securities][old_security_id]) + mapped_security_id = mapped_id(:securities, old_security_id, record_type: "Security", required: false) + if old_security_id.present? && mapped_security_id + security = Security.find(mapped_security_id) apply_security_metadata(security, normalized_ticker, attributes) @security_cache[cache_key] = security return security @@ -856,7 +1195,7 @@ class Family::DataImporter apply_security_metadata(security, normalized_ticker, attributes) @security_cache[cache_key] = security - @id_mappings[:securities][old_security_id] = security.id if old_security_id.present? + map_source!(:securities, old_security_id, security) if old_security_id.present? security end @@ -901,6 +1240,7 @@ class Family::DataImporter def upsert_imported_holding!(account, security, date, currency, attributes) holding = account.holdings.find_or_initialize_by(security: security, date: date, currency: currency) + created = holding.new_record? holding.assign_attributes(attributes) begin @@ -908,7 +1248,10 @@ class Family::DataImporter rescue ActiveRecord::RecordNotUnique existing = account.holdings.find_by!(security: security, date: date, currency: currency) existing.update!(attributes) + created = false end + + created end def security_kind_for(value) diff --git a/app/models/family/financial_data_reset.rb b/app/models/family/financial_data_reset.rb index 91759e239..021f1eab9 100644 --- a/app/models/family/financial_data_reset.rb +++ b/app/models/family/financial_data_reset.rb @@ -7,6 +7,8 @@ class Family::FinancialDataReset account_statements family_exports imports + import_sessions + import_source_mappings import_rows import_mappings accounts @@ -127,6 +129,7 @@ class Family::FinancialDataReset delete_active_storage_attachments! scope(:transfers).destroy_all scope(:rejected_transfers).destroy_all + scope(:import_source_mappings).destroy_all scope(:import_mappings).destroy_all scope(:import_rows).destroy_all scope(:rule_runs).destroy_all @@ -138,6 +141,7 @@ class Family::FinancialDataReset scope(:account_statements).destroy_all scope(:family_exports).destroy_all scope(:imports).destroy_all + scope(:import_sessions).destroy_all scope(:entries).destroy_all scope(:holdings).destroy_all scope(:balances).destroy_all @@ -239,6 +243,7 @@ class Family::FinancialDataReset account_scope = Account.where(family_id: family.id) account_ids = account_scope.select(:id) import_scope = Import.where(family_id: family.id) + import_session_scope = ImportSession.where(family_id: family.id) import_ids = import_scope.select(:id) rule_scope = Rule.where(family_id: family.id) rule_ids = rule_scope.select(:id) @@ -252,6 +257,8 @@ class Family::FinancialDataReset account_statements: AccountStatement.where(family_id: family.id), family_exports: FamilyExport.where(family_id: family.id), imports: import_scope, + import_sessions: import_session_scope, + import_source_mappings: ImportSourceMapping.where(family_id: family.id), import_rows: Import::Row.where(import_id: import_ids), import_mappings: Import::Mapping.where(import_id: import_ids), accounts: account_scope, diff --git a/app/models/import.rb b/app/models/import.rb index d70a19eb2..4ea45f684 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -41,11 +41,14 @@ class Import < ApplicationRecord belongs_to :family belongs_to :account, optional: true belongs_to :account_statement, optional: true + belongs_to :import_session, optional: true before_validation :set_default_number_format before_validation :ensure_utf8_encoding + normalizes :client_chunk_id, with: ->(value) { value.strip.presence } scope :ordered, -> { order(created_at: :desc) } + scope :ordered_by_sequence, -> { order(:sequence, :created_at) } enum :status, { pending: "pending", @@ -61,9 +64,15 @@ class Import < ApplicationRecord validates :col_sep, inclusion: { in: SEPARATORS.map(&:last) } validates :signage_convention, inclusion: { in: SIGNAGE_CONVENTIONS }, allow_nil: true validates :number_format, presence: true, inclusion: { in: NUMBER_FORMATS.keys } + validates :sequence, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true + validates :client_chunk_id, length: { maximum: 255 }, allow_blank: true + validates :checksum, length: { is: 64 }, allow_blank: true validate :custom_column_import_requires_identifier validates :rows_to_skip, numericality: { only_integer: true, greater_than_or_equal_to: 0 } validate :account_belongs_to_family + validate :import_session_belongs_to_family + validate :session_chunk_metadata + validate :session_payloads_are_json_objects validate :rows_to_skip_within_file_bounds has_many :rows, dependent: :destroy @@ -564,6 +573,25 @@ class Import < ApplicationRecord errors.add(:account, "must belong to your family") end + def import_session_belongs_to_family + return if import_session.nil? + return if import_session.family_id == family_id + + errors.add(:import_session, "must belong to your family") + end + + def session_chunk_metadata + return if import_session.nil? + + errors.add(:sequence, "must be present for import session chunks") if sequence.blank? + errors.add(:checksum, "must be present for import session chunks") if checksum.blank? + end + + def session_payloads_are_json_objects + errors.add(:summary, "must be an object") unless summary.is_a?(Hash) + errors.add(:error_details, "must be an object") unless error_details.is_a?(Hash) + end + def rows_to_skip_within_file_bounds return if raw_file_str.blank? return if rows_to_skip.to_i == 0 diff --git a/app/models/import_session.rb b/app/models/import_session.rb new file mode 100644 index 000000000..8ce8c1fd9 --- /dev/null +++ b/app/models/import_session.rb @@ -0,0 +1,425 @@ +require "digest" + +class ImportSession < ApplicationRecord + ConflictError = Class.new(StandardError) + EnqueueError = Class.new(StandardError) + + IMPORT_TYPES = %w[SureImport].freeze + STATUSES = %w[pending importing complete failed].freeze + + belongs_to :family + has_many :imports, -> { order(:sequence, :created_at) }, dependent: :destroy + has_many :source_mappings, + class_name: "ImportSourceMapping", + dependent: :destroy + + enum :status, { + pending: "pending", + importing: "importing", + complete: "complete", + failed: "failed" + }, validate: true, default: "pending" + + validates :import_type, inclusion: { in: IMPORT_TYPES } + validates :client_session_id, uniqueness: { scope: :family_id }, allow_blank: true + validates :client_session_id, length: { maximum: 255 }, allow_blank: true + normalizes :client_session_id, with: ->(value) { value.strip.presence } + validates :expected_chunks, + numericality: { only_integer: true, greater_than: 0 }, + allow_nil: true + validate :payloads_are_json_objects + + def self.create_or_find_for!(family:, import_type:, client_session_id:, expected_chunks:) + import_type = import_type.presence || "SureImport" + expected_chunks = normalize_positive_integer(expected_chunks) + unless IMPORT_TYPES.include?(import_type) + session = new(import_type: import_type) + session.errors.add(:import_type, "must be SureImport") + raise ActiveRecord::RecordInvalid.new(session) + end + + if client_session_id.present? + session = family.import_sessions.find_or_initialize_by(client_session_id: client_session_id) + if session.persisted? && + expected_chunks.present? && + session.expected_chunks.present? && + session.expected_chunks != expected_chunks + raise ConflictError, "client_session_id already exists with a different expected_chunks value" + end + else + session = family.import_sessions.build + end + + session.import_type = import_type + session.expected_chunks ||= expected_chunks + session.save! + session + rescue ActiveRecord::RecordNotUnique + raise unless client_session_id.present? + + existing = family.import_sessions.find_by(client_session_id: client_session_id) + raise unless existing + + if expected_chunks.present? && + existing.expected_chunks.present? && + existing.expected_chunks != expected_chunks + raise ConflictError, "client_session_id already exists with a different expected_chunks value" + end + if expected_chunks.present? && existing.expected_chunks.nil? + existing.update!(expected_chunks: expected_chunks) + end + + existing + end + + def self.normalize_positive_integer(value) + return if value.blank? + + Integer(value, exception: false) || 0 + end + private_class_method :normalize_positive_integer + + def attach_chunk!(sequence:, content:, filename:, content_type:, client_chunk_id: nil) + sequence = self.class.send(:normalize_positive_integer, sequence) + raise ConflictError, "sequence must be a positive integer" unless sequence.positive? + raise ConflictError, "sequence exceeds expected_chunks" if expected_chunks.present? && sequence > expected_chunks + + checksum = Digest::SHA256.hexdigest(content) + normalized_client_chunk_id = client_chunk_id.presence + chunk_needs_finalization = false + + chunk = with_lock do + raise ConflictError, "cannot add chunks after publishing starts" unless pending? || failed? + + existing = existing_chunk_for!( + sequence: sequence, + client_chunk_id: normalized_client_chunk_id, + checksum: checksum + ) + + if existing + chunk_needs_finalization = prepare_existing_chunk_for_retry!( + existing, + checksum: checksum, + content: content, + filename: filename, + content_type: content_type + ) + existing + else + chunk_needs_finalization = true + chunk = create_chunk!( + sequence: sequence, + client_chunk_id: normalized_client_chunk_id, + checksum: checksum, + content: content, + filename: filename, + content_type: content_type + ) + end + end + + finalize_chunk_for_retry!(chunk, checksum) if chunk_needs_finalization + chunk + rescue ActiveRecord::RecordNotUnique + imports.reset + existing = existing_chunk_for!( + sequence: sequence, + client_chunk_id: normalized_client_chunk_id, + checksum: checksum + ) + return prepare_and_finalize_existing_chunk!( + existing, + checksum: checksum, + content: content, + filename: filename, + content_type: content_type + ) if existing + + raise ConflictError, "chunk already exists with different content" + end + + def create_chunk!(sequence:, client_chunk_id:, checksum:, content:, filename:, content_type:) + imports.create!( + family: family, + type: "SureImport", + sequence: sequence, + client_chunk_id: client_chunk_id, + checksum: checksum + ).tap do |import| + import.ndjson_file.attach( + io: StringIO.new(content), + filename: filename, + content_type: content_type + ) + end + end + private :create_chunk! + + def publish_later + previous_status = nil + should_enqueue = false + + sync_chunk_row_counts! + + with_lock do + return if complete? || importing? + + validate_publishable_chunks! + + previous_status = status + update!(status: :importing, error_details: {}) + should_enqueue = true + end + + return unless should_enqueue + + begin + ImportSessionJob.perform_later(self) + rescue => error + with_lock do + reload + if importing? + update!(status: previous_status, error_details: enqueue_error_details) + end + end + Rails.logger.error("ImportSession enqueue failed import_session_id=#{id} exception=#{error.class}") + raise EnqueueError, "Import session could not be queued." + end + end + + def publish + return unless prepare_for_publish! + + Rails.logger.info("ImportSession publish started import_session_id=#{id}") + + imports.ordered_by_sequence.each do |import| + process_chunk!(import) + end + + update!(status: :complete, summary: aggregate_chunk_summaries, error_details: {}) + enqueue_family_sync + Rails.logger.info("ImportSession publish completed import_session_id=#{id}") + rescue => error + update!( + status: :failed, + error_details: error_details_for(error), + summary: aggregate_chunk_summaries + ) + Rails.logger.error("ImportSession publish failed import_session_id=#{id} exception=#{error.class}") + end + + def aggregate_chunk_summaries + imports.reload.each_with_object({}) do |import, totals| + merge_summary!(totals, import.summary || {}) + end + end + + private + def prepare_for_publish! + sync_chunk_row_counts! + + with_lock do + return false if complete? + + validate_publishable_chunks! + + update!(status: :importing, error_details: {}) unless importing? + true + end + end + + def enqueue_family_sync + family.sync_later + rescue => error + update!(error_details: sync_enqueue_error_details) + Rails.logger.error( + "ImportSession family sync enqueue failed import_session_id=#{id} exception=#{error.class}" + ) + end + + def existing_chunk_for!(sequence:, client_chunk_id:, checksum:) + sequence_match = imports.find_by(sequence: sequence) + client_chunk_match = imports.find_by(client_chunk_id: client_chunk_id) if client_chunk_id.present? + + if sequence_match && client_chunk_match && sequence_match.id != client_chunk_match.id + raise ConflictError, "sequence and client_chunk_id refer to different chunks" + end + + existing = sequence_match || client_chunk_match + return unless existing + + if existing.sequence != sequence + raise ConflictError, "client_chunk_id already exists with a different sequence" + end + + if client_chunk_id.present? && existing.client_chunk_id.present? && existing.client_chunk_id != client_chunk_id + raise ConflictError, "sequence already exists with a different client_chunk_id" + end + + raise ConflictError, "chunk already exists with different content" unless existing.checksum == checksum + + existing + end + + def prepare_and_finalize_existing_chunk!(chunk, checksum:, content:, filename:, content_type:) + needs_finalization = with_lock do + prepare_existing_chunk_for_retry!( + chunk.reload, + checksum: checksum, + content: content, + filename: filename, + content_type: content_type + ) + end + + finalize_chunk_for_retry!(chunk, checksum) if needs_finalization + chunk + end + + def prepare_existing_chunk_for_retry!(chunk, checksum:, content:, filename:, content_type:) + return false if chunk_ready_for_retry?(chunk, checksum) + return true if chunk.ndjson_file.attached? && chunk_content_checksum(chunk) == checksum + + chunk.ndjson_file.attach( + io: StringIO.new(content), + filename: filename, + content_type: content_type + ) + true + end + + def finalize_chunk_for_retry!(chunk, checksum) + chunk.sync_ndjson_rows_count! + chunk.reload + return chunk if chunk_ready_for_retry?(chunk, checksum) + + raise ConflictError, "chunk already exists but is incomplete" + rescue ActiveStorage::FileNotFoundError + raise ConflictError, "chunk already exists but is incomplete" + end + + def chunk_ready_for_retry?(chunk, checksum) + chunk.ndjson_file.attached? && + chunk.rows_count.to_i.positive? && + chunk_content_checksum(chunk) == checksum + end + + def chunk_content_checksum(chunk) + Digest::SHA256.hexdigest(chunk.ndjson_file.download) + rescue ActiveStorage::FileNotFoundError + nil + end + + def process_chunk!(import) + return if import.complete? + + import.update!(status: :importing, error: nil, error_details: {}) + result = import.import!(import_session: self) + import.update!(status: :complete, summary: result.fetch(:summary, {}), error_details: {}) + rescue => error + import.update!( + status: :failed, + error: public_error_message_for(error), + error_details: error_details_for(error), + summary: failed_summary_for(error) + ) + raise + end + + def row_count_exceeded? + imports.sum(:rows_count) > SureImport.max_row_count + end + + def validate_publishable_chunks! + raise ConflictError, "import session has no chunks" unless imports.exists? + raise Import::MaxRowCountExceededError if row_count_exceeded? + validate_expected_chunk_sequences! + end + + def sync_chunk_row_counts! + raise ConflictError, "import session has no chunks" unless imports.exists? + imports.reload.each(&:sync_ndjson_rows_count!) + rescue ActiveStorage::FileNotFoundError + raise ConflictError, "import session chunks are incomplete" + end + + def validate_expected_chunk_sequences! + return if expected_chunks.blank? + + expected_sequences = (1..expected_chunks).to_a + actual_sequences = imports.pluck(:sequence).sort + return if actual_sequences == expected_sequences + + missing_sequences = expected_sequences - actual_sequences + unexpected_sequences = actual_sequences - expected_sequences + details = [] + details << "missing sequences: #{missing_sequences.join(', ')}" if missing_sequences.any? + details << "unexpected sequences: #{unexpected_sequences.join(', ')}" if unexpected_sequences.any? + + raise ConflictError, "import session chunks do not match expected sequences (#{details.join('; ')})" + end + + def error_details_for(error) + details = { + "code" => error.respond_to?(:code) ? error.code : "import_failed", + "message" => public_error_message_for(error) + } + + if error.respond_to?(:details) + details.merge!(error.details.stringify_keys) + end + + details + end + + def public_error_message_for(error) + return error.message if error.respond_to?(:code) + + "Import session failed." + end + + def enqueue_error_details + { + "code" => "import_enqueue_failed", + "message" => "Import session could not be queued." + } + end + + def sync_enqueue_error_details + { + "code" => "family_sync_enqueue_failed", + "message" => "Family sync could not be queued after import completion." + } + end + + def merge_summary!(totals, summary) + summary.each do |entity_type, counts| + next unless counts.respond_to?(:each) + + totals[entity_type] ||= {} + counts.each do |status, count| + totals[entity_type][status] = totals[entity_type].fetch(status, 0) + count.to_i + end + end + end + + def failed_summary_for(error) + record_type = error_details_for(error)["record_type"] + return {} if record_type.blank? + + { + record_type.to_s.underscore.pluralize => { + "created" => 0, + "updated" => 0, + "skipped" => 0, + "failed" => 1 + } + } + end + + def payloads_are_json_objects + errors.add(:summary, "must be an object") unless summary.is_a?(Hash) + errors.add(:error_details, "must be an object") unless error_details.is_a?(Hash) + end +end diff --git a/app/models/import_source_mapping.rb b/app/models/import_source_mapping.rb new file mode 100644 index 000000000..c59bd8a81 --- /dev/null +++ b/app/models/import_source_mapping.rb @@ -0,0 +1,41 @@ +class ImportSourceMapping < ApplicationRecord + SOURCE_TYPES = %w[Account Category Tag Merchant RecurringTransaction Transaction Budget Security Rule].freeze + + belongs_to :family + belongs_to :import_session + belongs_to :target, polymorphic: true, optional: true + + validates :source_type, :source_id, :target_type, :target_id, presence: true + validates :source_type, inclusion: { in: SOURCE_TYPES } + validates :target_type, inclusion: { in: SOURCE_TYPES }, allow_blank: true + validates :source_type, length: { maximum: 64 } + validates :source_id, length: { maximum: 255 } + validates :source_id, uniqueness: { scope: [ :import_session_id, :source_type ] } + normalizes :source_type, :source_id, with: ->(value) { value.strip.presence } + validate :family_matches_import_session + validate :target_exists + validate :target_matches_family + + private + def family_matches_import_session + return if import_session.blank? || family_id == import_session.family_id + + errors.add(:family, "must match import session") + end + + def target_exists + return if target_type.blank? || target_id.blank? || !SOURCE_TYPES.include?(target_type) + return if target.present? + + errors.add(:target, "must exist") + end + + def target_matches_family + return if target_type.blank? || !SOURCE_TYPES.include?(target_type) + return if target.blank? + return unless target.respond_to?(:family_id) + return if target.family_id == family_id + + errors.add(:target, "must belong to your family") + end +end diff --git a/app/models/sure_import.rb b/app/models/sure_import.rb index c738e6156..096b3771d 100644 --- a/app/models/sure_import.rb +++ b/app/models/sure_import.rb @@ -129,16 +129,28 @@ class SureImport < Import self.class.dry_run_totals_from_ndjson(ndjson_blob_string) end - def import! + def import!(import_session: nil) sync_ndjson_counts! before_counts = readback_count_snapshot - importer = Family::DataImporter.new(family, ndjson_blob_string) + importer = Family::DataImporter.new(family, ndjson_blob_string, import_session: import_session, import: self) result = importer.import! - result[:accounts].each { |account| accounts << account } - result[:entries].each { |entry| entries << entry } + Import.transaction do + result[:accounts].each { |account| account.save! if account.new_record? } + result[:entries].each { |entry| entry.save! if entry.new_record? } + + account_ids = result[:accounts].filter_map(&:id) + entry_ids = result[:entries].filter_map(&:id) + existing_account_ids = accounts.where(id: account_ids).pluck(:id) + existing_entry_ids = entries.where(id: entry_ids).pluck(:id) + + accounts.concat(result[:accounts].reject { |account| existing_account_ids.include?(account.id) }) + entries.concat(result[:entries].reject { |entry| existing_entry_ids.include?(entry.id) }) + update!(summary: result[:summary]) if has_attribute?(:summary) + end record_readback_verification!(before_counts:) + result rescue => error record_failed_readback_verification!(before_counts:, error:) raise diff --git a/config/routes.rb b/config/routes.rb index fb4355deb..53bdc864a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -536,6 +536,10 @@ Rails.application.routes.draw do post :preflight, on: :collection get :rows, on: :member end + resources :import_sessions, only: [ :show, :create ] do + post :chunks, on: :member, action: :create_chunk + post :publish, on: :member + end resource :usage, only: [ :show ], controller: :usage resource :balance_sheet, only: [ :show ], controller: :balance_sheet resource :family_settings, only: [ :show ], controller: :family_settings diff --git a/db/migrate/20260513013000_create_import_sessions.rb b/db/migrate/20260513013000_create_import_sessions.rb new file mode 100644 index 000000000..743d7f4bd --- /dev/null +++ b/db/migrate/20260513013000_create_import_sessions.rb @@ -0,0 +1,78 @@ +class CreateImportSessions < ActiveRecord::Migration[7.2] + def change + create_table :import_sessions, id: :uuid do |t| + t.references :family, null: false, foreign_key: true, type: :uuid + t.string :import_type, null: false, default: "SureImport" + t.string :status, null: false, default: "pending" + t.string :client_session_id, limit: 255 + t.integer :expected_chunks + t.jsonb :summary, null: false, default: {} + t.jsonb :error_details, null: false, default: {} + + t.timestamps + + t.index [ :family_id, :client_session_id ], + unique: true, + where: "client_session_id IS NOT NULL", + name: "idx_import_sessions_on_family_client_session" + t.index [ :family_id, :status ] + t.index [ :id, :family_id ], unique: true, name: "idx_import_sessions_on_id_family" + t.check_constraint "expected_chunks IS NULL OR expected_chunks > 0", name: "chk_import_sessions_expected_chunks_positive" + t.check_constraint "client_session_id IS NULL OR btrim(client_session_id) <> ''", + name: "chk_import_sessions_client_session_id_present" + t.check_constraint "import_type = 'SureImport'", name: "chk_import_sessions_import_type" + t.check_constraint "status IN ('pending', 'importing', 'complete', 'failed')", name: "chk_import_sessions_status" + t.check_constraint "jsonb_typeof(summary) = 'object'", name: "chk_import_sessions_summary_object" + t.check_constraint "jsonb_typeof(error_details) = 'object'", name: "chk_import_sessions_error_details_object" + end + + create_table :import_source_mappings, id: :uuid do |t| + t.references :family, null: false, foreign_key: true, type: :uuid + t.references :import_session, null: false, type: :uuid + t.string :source_type, null: false, limit: 64 + t.string :source_id, null: false, limit: 255 + t.references :target, polymorphic: true, null: false, type: :uuid, + index: { name: "idx_import_source_mappings_on_target" } + + t.timestamps + + t.index [ :import_session_id, :source_type, :source_id ], + unique: true, + name: "index_import_source_mappings_on_session_type_and_source" + t.index [ :family_id, :source_type, :source_id ], name: "idx_import_source_mappings_on_family_source" + t.check_constraint "btrim(source_type) <> ''", name: "chk_import_source_mappings_source_type_present" + t.check_constraint "source_type IN ('Account', 'Category', 'Tag', 'Merchant', 'RecurringTransaction', 'Transaction', 'Budget', 'Security', 'Rule')", + name: "chk_import_source_mappings_source_type" + t.check_constraint "btrim(source_id) <> ''", name: "chk_import_source_mappings_source_id_present" + t.check_constraint "btrim(target_type) <> ''", name: "chk_import_source_mappings_target_type_present" + t.check_constraint "target_type IN ('Account', 'Category', 'Tag', 'Merchant', 'RecurringTransaction', 'Transaction', 'Budget', 'Security', 'Rule')", + name: "chk_import_source_mappings_target_type" + end + + add_foreign_key :import_source_mappings, :import_sessions, + column: [ :import_session_id, :family_id ], primary_key: [ :id, :family_id ], + on_delete: :cascade, name: "fk_import_source_mappings_session_family" + + add_reference :imports, :import_session, type: :uuid + add_column :imports, :sequence, :integer + add_column :imports, :client_chunk_id, :string, limit: 255 + add_column :imports, :checksum, :string, limit: 64 + add_column :imports, :summary, :jsonb, null: false, default: {} + add_column :imports, :error_details, :jsonb, null: false, default: {} + + add_index :imports, [ :import_session_id, :sequence ], unique: true, + where: "import_session_id IS NOT NULL AND sequence IS NOT NULL", name: "idx_imports_on_session_sequence" + add_index :imports, [ :import_session_id, :client_chunk_id ], unique: true, + where: "import_session_id IS NOT NULL AND client_chunk_id IS NOT NULL", name: "idx_imports_on_session_client_chunk" + add_foreign_key :imports, :import_sessions, + column: [ :import_session_id, :family_id ], primary_key: [ :id, :family_id ], + on_delete: :cascade, name: "fk_imports_session_family" + add_check_constraint :imports, "sequence IS NULL OR sequence > 0", name: "chk_imports_session_sequence_positive" + add_check_constraint :imports, "client_chunk_id IS NULL OR btrim(client_chunk_id) <> ''", name: "chk_imports_client_chunk_id_present" + add_check_constraint :imports, "checksum IS NULL OR length(checksum) = 64", name: "chk_imports_checksum_sha256_length" + add_check_constraint :imports, "import_session_id IS NULL OR sequence IS NOT NULL", name: "chk_imports_session_sequence_present" + add_check_constraint :imports, "import_session_id IS NULL OR checksum IS NOT NULL", name: "chk_imports_session_checksum_present" + add_check_constraint :imports, "jsonb_typeof(summary) = 'object'", name: "chk_imports_summary_object" + add_check_constraint :imports, "jsonb_typeof(error_details) = 'object'", name: "chk_imports_error_details_object" + end +end diff --git a/db/schema.rb b/db/schema.rb index 513bd1abd..7f67f2646 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -982,6 +982,49 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_153000) do t.check_constraint "source_row_number > 0", name: "chk_import_rows_source_row_number_positive" end + create_table "import_sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "family_id", null: false + t.string "import_type", default: "SureImport", null: false + t.string "status", default: "pending", null: false + t.string "client_session_id", limit: 255 + t.integer "expected_chunks" + t.jsonb "summary", default: {}, null: false + t.jsonb "error_details", default: {}, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["family_id", "client_session_id"], name: "idx_import_sessions_on_family_client_session", unique: true, where: "(client_session_id IS NOT NULL)" + t.index ["family_id", "status"], name: "index_import_sessions_on_family_id_and_status" + t.index ["family_id"], name: "index_import_sessions_on_family_id" + t.index ["id", "family_id"], name: "idx_import_sessions_on_id_family", unique: true + t.check_constraint "client_session_id IS NULL OR btrim(client_session_id::text) <> ''::text", name: "chk_import_sessions_client_session_id_present" + t.check_constraint "expected_chunks IS NULL OR expected_chunks > 0", name: "chk_import_sessions_expected_chunks_positive" + t.check_constraint "jsonb_typeof(error_details) = 'object'::text", name: "chk_import_sessions_error_details_object" + t.check_constraint "import_type::text = 'SureImport'::text", name: "chk_import_sessions_import_type" + t.check_constraint "status::text = ANY (ARRAY['pending'::character varying, 'importing'::character varying, 'complete'::character varying, 'failed'::character varying]::text[])", name: "chk_import_sessions_status" + t.check_constraint "jsonb_typeof(summary) = 'object'::text", name: "chk_import_sessions_summary_object" + end + + create_table "import_source_mappings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "family_id", null: false + t.uuid "import_session_id", null: false + t.string "source_type", limit: 64, null: false + t.string "source_id", limit: 255, null: false + t.string "target_type", null: false + t.uuid "target_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["family_id", "source_type", "source_id"], name: "idx_import_source_mappings_on_family_source" + t.index ["family_id"], name: "index_import_source_mappings_on_family_id" + t.index ["import_session_id", "source_type", "source_id"], name: "index_import_source_mappings_on_session_type_and_source", unique: true + t.index ["import_session_id"], name: "index_import_source_mappings_on_import_session_id" + t.index ["target_type", "target_id"], name: "idx_import_source_mappings_on_target" + t.check_constraint "btrim(source_id::text) <> ''::text", name: "chk_import_source_mappings_source_id_present" + t.check_constraint "source_type::text = ANY (ARRAY['Account'::character varying, 'Category'::character varying, 'Tag'::character varying, 'Merchant'::character varying, 'RecurringTransaction'::character varying, 'Transaction'::character varying, 'Budget'::character varying, 'Security'::character varying, 'Rule'::character varying]::text[])", name: "chk_import_source_mappings_source_type" + t.check_constraint "btrim(source_type::text) <> ''::text", name: "chk_import_source_mappings_source_type_present" + t.check_constraint "target_type::text = ANY (ARRAY['Account'::character varying, 'Category'::character varying, 'Tag'::character varying, 'Merchant'::character varying, 'RecurringTransaction'::character varying, 'Transaction'::character varying, 'Budget'::character varying, 'Security'::character varying, 'Rule'::character varying]::text[])", name: "chk_import_source_mappings_target_type" + t.check_constraint "btrim(target_type::text) <> ''::text", name: "chk_import_source_mappings_target_type_present" + end + create_table "imports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.jsonb "column_mappings" t.string "status" @@ -1021,8 +1064,24 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_153000) do t.uuid "account_statement_id" t.jsonb "expected_record_counts", default: {}, null: false t.jsonb "readback_verification", default: {}, null: false + t.uuid "import_session_id" + t.integer "sequence" + t.string "client_chunk_id", limit: 255 + t.string "checksum", limit: 64 + t.jsonb "summary", default: {}, null: false + t.jsonb "error_details", default: {}, null: false t.index ["account_statement_id"], name: "index_imports_on_account_statement_id" t.index ["family_id"], name: "index_imports_on_family_id" + t.index ["import_session_id", "client_chunk_id"], name: "idx_imports_on_session_client_chunk", unique: true, where: "((import_session_id IS NOT NULL) AND (client_chunk_id IS NOT NULL))" + t.index ["import_session_id", "sequence"], name: "idx_imports_on_session_sequence", unique: true, where: "((import_session_id IS NOT NULL) AND (sequence IS NOT NULL))" + t.index ["import_session_id"], name: "index_imports_on_import_session_id" + t.check_constraint "checksum IS NULL OR length(checksum::text) = 64", name: "chk_imports_checksum_sha256_length" + t.check_constraint "client_chunk_id IS NULL OR btrim(client_chunk_id::text) <> ''::text", name: "chk_imports_client_chunk_id_present" + t.check_constraint "jsonb_typeof(error_details) = 'object'::text", name: "chk_imports_error_details_object" + t.check_constraint "import_session_id IS NULL OR checksum IS NOT NULL", name: "chk_imports_session_checksum_present" + t.check_constraint "import_session_id IS NULL OR sequence IS NOT NULL", name: "chk_imports_session_sequence_present" + t.check_constraint "jsonb_typeof(summary) = 'object'::text", name: "chk_imports_summary_object" + t.check_constraint "sequence IS NULL OR sequence > 0", name: "chk_imports_session_sequence_positive" end create_table "indexa_capital_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -2043,8 +2102,12 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_153000) do add_foreign_key "impersonation_sessions", "users", column: "impersonated_id" add_foreign_key "impersonation_sessions", "users", column: "impersonator_id" add_foreign_key "import_rows", "imports" + add_foreign_key "import_sessions", "families" + add_foreign_key "import_source_mappings", "families" + add_foreign_key "import_source_mappings", "import_sessions", column: ["import_session_id", "family_id"], primary_key: ["id", "family_id"], name: "fk_import_source_mappings_session_family", on_delete: :cascade add_foreign_key "imports", "account_statements", on_delete: :nullify add_foreign_key "imports", "families" + add_foreign_key "imports", "import_sessions", column: ["import_session_id", "family_id"], primary_key: ["id", "family_id"], name: "fk_imports_session_family", on_delete: :cascade add_foreign_key "indexa_capital_accounts", "indexa_capital_items" add_foreign_key "indexa_capital_items", "families" add_foreign_key "invitations", "families" diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3a1adc911..a8f224d61 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2086,6 +2086,115 @@ components: properties: data: "$ref": "#/components/schemas/ImportDetail" + ImportSessionChunk: + type: object + required: + - id + - sequence + - status + - rows_count + - summary + - created_at + - updated_at + properties: + id: + type: string + format: uuid + sequence: + type: integer + minimum: 1 + client_chunk_id: + type: string + nullable: true + status: + type: string + enum: + - pending + - importing + - complete + - failed + rows_count: + type: integer + minimum: 0 + summary: + type: object + additionalProperties: + type: object + additionalProperties: + type: integer + error: + type: object + nullable: true + additionalProperties: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + ImportSession: + type: object + required: + - id + - type + - status + - chunks_count + - summary + - chunks + - created_at + - updated_at + properties: + id: + type: string + format: uuid + type: + type: string + enum: + - SureImport + status: + type: string + enum: + - pending + - importing + - complete + - failed + client_session_id: + type: string + nullable: true + expected_chunks: + type: integer + nullable: true + minimum: 1 + chunks_count: + type: integer + minimum: 0 + summary: + type: object + additionalProperties: + type: object + additionalProperties: + type: integer + error: + type: object + nullable: true + additionalProperties: true + chunks: + type: array + items: + "$ref": "#/components/schemas/ImportSessionChunk" + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + ImportSessionResponse: + type: object + required: + - data + properties: + data: + "$ref": "#/components/schemas/ImportSession" ProviderConnectionInstitution: type: object required: @@ -2892,6 +3001,8 @@ components: - account_statements - family_exports - imports + - import_sessions + - import_source_mappings - import_rows - import_mappings - accounts @@ -2933,6 +3044,12 @@ components: imports: type: integer minimum: 0 + import_sessions: + type: integer + minimum: 0 + import_source_mappings: + type: integer + minimum: 0 import_rows: type: integer minimum: 0 @@ -4607,6 +4724,266 @@ paths: application/json: schema: "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/import_sessions": + post: + summary: Create import session + description: Create or idempotently retrieve a multi-file SureImport session + keyed by client_session_id. + tags: + - Import Sessions + security: + - apiKeyAuth: [] + parameters: [] + responses: + '201': + description: import session created + content: + application/json: + schema: + "$ref": "#/components/schemas/ImportSessionResponse" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: insufficient scope + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '409': + description: client session conflict + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '422': + description: validation error + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + requestBody: + content: + application/json: + schema: + type: object + properties: + type: + type: string + enum: + - SureImport + description: Import session type. Only SureImport is supported. + client_session_id: + type: string + nullable: true + description: Client-provided idempotency key for the full import + session. + expected_chunks: + type: integer + minimum: 1 + nullable: true + description: Expected number of ordered chunks before publish is + allowed. + "/api/v1/import_sessions/{id}": + parameters: + - name: id + in: path + required: true + description: Import session ID + schema: + type: string + get: + summary: Retrieve import session + description: Retrieve import session status, chunk status, per-entity summary + counts, and safe error details. + tags: + - Import Sessions + security: + - apiKeyAuth: [] + responses: + '200': + description: import session retrieved + content: + application/json: + schema: + "$ref": "#/components/schemas/ImportSessionResponse" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: insufficient scope + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '404': + description: import session not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/import_sessions/{id}/chunks": + parameters: + - name: id + in: path + required: true + description: Import session ID + schema: + type: string + post: + summary: Upload import session chunk + description: Attach an ordered Sure NDJSON chunk to an import session. Chunks + are idempotent by sequence and client_chunk_id with content verification. + tags: + - Import Sessions + security: + - apiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - sequence + - raw_file_content + properties: + sequence: + type: integer + minimum: 1 + description: One-based chunk sequence. Earlier dependency chunks + must have lower sequence numbers. + client_chunk_id: + type: string + nullable: true + description: Client-provided idempotency key for this chunk. + raw_file_content: + type: string + description: Raw Sure NDJSON content. Each chunk is limited to 10MB. + multipart/form-data: + schema: + type: object + required: + - sequence + - file + properties: + sequence: + type: integer + minimum: 1 + description: One-based chunk sequence. Earlier dependency chunks + must have lower sequence numbers. + client_chunk_id: + type: string + nullable: true + description: Client-provided idempotency key for this chunk. + file: + type: string + format: binary + description: Multipart Sure NDJSON file upload. Each chunk is limited + to 10MB. + parameters: [] + responses: + '201': + description: chunk uploaded + content: + application/json: + schema: + "$ref": "#/components/schemas/ImportSessionResponse" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: insufficient scope + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '409': + description: chunk conflict + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '404': + description: import session not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '422': + description: missing or invalid content + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/import_sessions/{id}/publish": + parameters: + - name: id + in: path + required: true + description: Import session ID + schema: + type: string + post: + summary: Publish import session + description: Queue ordered chunk processing for a SureImport session. Later + chunks can reference source IDs mapped by earlier chunks. + tags: + - Import Sessions + security: + - apiKeyAuth: [] + responses: + '202': + description: import session publish queued + content: + application/json: + schema: + "$ref": "#/components/schemas/ImportSessionResponse" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: insufficient scope + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '422': + description: max_row_count_exceeded + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '409': + description: missing expected chunks + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '503': + description: enqueue failed + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '404': + description: import session not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" "/api/v1/imports": get: summary: List imports diff --git a/spec/requests/api/v1/import_sessions_spec.rb b/spec/requests/api/v1/import_sessions_spec.rb new file mode 100644 index 000000000..02c9af9f9 --- /dev/null +++ b/spec/requests/api/v1/import_sessions_spec.rb @@ -0,0 +1,430 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'API V1 Import Sessions', type: :request do + let(:user) { users(:empty) } + let(:family) { user.family } + + let(:api_key) { api_keys(:active_key) } + let(:api_key_without_write_scope) { api_keys(:one) } + let(:api_key_without_read_scope) { api_keys(:expired_key) } + + let(:'X-Api-Key') { api_key.plain_key } + + let(:entity_ndjson) do + { + type: 'Account', + data: { + id: 'docs-account-1', + name: 'Docs Checking', + balance: '100.00', + currency: 'USD', + accountable_type: 'Depository' + } + }.to_json + end + + let(:transaction_ndjson) do + { + type: 'Transaction', + data: { + id: 'docs-transaction-1', + account_id: 'docs-account-1', + date: '2024-01-15', + amount: '-12.34', + currency: 'USD', + name: 'Docs Transaction' + } + }.to_json + end + + path '/api/v1/import_sessions' do + post 'Create import session' do + description 'Create or idempotently retrieve a multi-file SureImport session keyed by client_session_id.' + tags 'Import Sessions' + security [ { apiKeyAuth: [] } ] + consumes 'application/json' + produces 'application/json' + + parameter name: :body, in: :body, required: false, schema: { + type: :object, + properties: { + type: { + type: :string, + enum: %w[SureImport], + description: 'Import session type. Only SureImport is supported.' + }, + client_session_id: { + type: :string, + nullable: true, + description: 'Client-provided idempotency key for the full import session.' + }, + expected_chunks: { + type: :integer, + minimum: 1, + nullable: true, + description: 'Expected number of ordered chunks before publish is allowed.' + } + } + } + + response '201', 'import session created' do + schema '$ref' => '#/components/schemas/ImportSessionResponse' + + let(:body) do + { + type: 'SureImport', + client_session_id: 'docs-session-1', + expected_chunks: 2 + } + end + + run_test! + end + + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { nil } + let(:body) { { type: 'SureImport' } } + + run_test! + end + + response '403', 'insufficient scope' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { api_key_without_write_scope.plain_key } + let(:body) { { type: 'SureImport' } } + + run_test! + end + + response '409', 'client session conflict' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + before do + family.import_sessions.create!( + client_session_id: 'docs-session-conflict', + expected_chunks: 1 + ) + end + + let(:body) do + { + type: 'SureImport', + client_session_id: 'docs-session-conflict', + expected_chunks: 2 + } + end + + run_test! + end + + response '422', 'validation error' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:body) { { type: 'TransactionImport' } } + + run_test! + end + end + end + + path '/api/v1/import_sessions/{id}' do + parameter name: :id, in: :path, type: :string, required: true, description: 'Import session ID' + + let(:import_session) { family.import_sessions.create!(expected_chunks: 1) } + + get 'Retrieve import session' do + description 'Retrieve import session status, chunk status, per-entity summary counts, and safe error details.' + tags 'Import Sessions' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + + let(:id) { import_session.id } + + response '200', 'import session retrieved' do + schema '$ref' => '#/components/schemas/ImportSessionResponse' + + run_test! + end + + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { nil } + + run_test! + end + + response '403', 'insufficient scope' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { api_key_without_read_scope.plain_key } + + run_test! + end + + response '404', 'import session not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:id) { SecureRandom.uuid } + + run_test! + end + end + end + + path '/api/v1/import_sessions/{id}/chunks' do + parameter name: :id, in: :path, type: :string, required: true, description: 'Import session ID' + + let(:import_session) { family.import_sessions.create!(expected_chunks: 2) } + let(:id) { import_session.id } + + post 'Upload import session chunk' do + description 'Attach an ordered Sure NDJSON chunk to an import session. Chunks are idempotent by sequence and client_chunk_id with content verification.' + tags 'Import Sessions' + security [ { apiKeyAuth: [] } ] + consumes 'application/json', 'multipart/form-data' + produces 'application/json' + metadata[:operation][:requestBody] = { + required: true, + content: { + 'application/json' => { + schema: { + type: :object, + required: %w[sequence raw_file_content], + properties: { + sequence: { + type: :integer, + minimum: 1, + description: 'One-based chunk sequence. Earlier dependency chunks must have lower sequence numbers.' + }, + client_chunk_id: { + type: :string, + nullable: true, + description: 'Client-provided idempotency key for this chunk.' + }, + raw_file_content: { + type: :string, + description: 'Raw Sure NDJSON content. Each chunk is limited to 10MB.' + } + } + } + }, + 'multipart/form-data' => { + schema: { + type: :object, + required: %w[sequence file], + properties: { + sequence: { + type: :integer, + minimum: 1, + description: 'One-based chunk sequence. Earlier dependency chunks must have lower sequence numbers.' + }, + client_chunk_id: { + type: :string, + nullable: true, + description: 'Client-provided idempotency key for this chunk.' + }, + file: { + type: :string, + format: :binary, + description: 'Multipart Sure NDJSON file upload. Each chunk is limited to 10MB.' + } + } + } + } + } + } + + parameter name: :body, in: :body, required: false + + response '201', 'chunk uploaded' do + schema '$ref' => '#/components/schemas/ImportSessionResponse' + + let(:body) do + { + sequence: 1, + client_chunk_id: 'docs-entities', + raw_file_content: entity_ndjson + } + end + + run_test! + end + + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { nil } + let(:body) { { sequence: 1, raw_file_content: entity_ndjson } } + + run_test! + end + + response '403', 'insufficient scope' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { api_key_without_write_scope.plain_key } + let(:body) { { sequence: 1, raw_file_content: entity_ndjson } } + + run_test! + end + + response '409', 'chunk conflict' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + before do + import_session.attach_chunk!( + sequence: 1, + client_chunk_id: 'docs-entities', + content: entity_ndjson, + filename: 'entities.ndjson', + content_type: 'application/x-ndjson' + ) + end + + let(:body) do + { + sequence: 1, + client_chunk_id: 'docs-entities', + raw_file_content: transaction_ndjson + } + end + + run_test! + end + + response '404', 'import session not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:id) { SecureRandom.uuid } + let(:body) { { sequence: 1, raw_file_content: entity_ndjson } } + + run_test! + end + + response '422', 'missing or invalid content' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:body) { { sequence: 1 } } + + run_test! + end + end + end + + path '/api/v1/import_sessions/{id}/publish' do + parameter name: :id, in: :path, type: :string, required: true, description: 'Import session ID' + + let(:import_session) { family.import_sessions.create!(expected_chunks: 1) } + let(:id) { import_session.id } + + post 'Publish import session' do + description 'Queue ordered chunk processing for a SureImport session. Later chunks can reference source IDs mapped by earlier chunks.' + tags 'Import Sessions' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + + response '202', 'import session publish queued' do + schema '$ref' => '#/components/schemas/ImportSessionResponse' + + before do + import_session.attach_chunk!( + sequence: 1, + client_chunk_id: 'docs-entities', + content: entity_ndjson, + filename: 'entities.ndjson', + content_type: 'application/x-ndjson' + ) + end + + run_test! + end + + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { nil } + + run_test! + end + + response '403', 'insufficient scope' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { api_key_without_write_scope.plain_key } + + run_test! + end + + response '422', 'max_row_count_exceeded' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + before do + import_session.attach_chunk!( + sequence: 1, + client_chunk_id: 'docs-entities', + content: entity_ndjson, + filename: 'entities.ndjson', + content_type: 'application/x-ndjson' + ) + import_session.imports.update_all(rows_count: SureImport.max_row_count + 1) + end + + run_test! + end + + response '409', 'missing expected chunks' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:import_session) { family.import_sessions.create!(expected_chunks: 2) } + + before do + import_session.attach_chunk!( + sequence: 1, + client_chunk_id: 'docs-entities', + content: entity_ndjson, + filename: 'entities.ndjson', + content_type: 'application/x-ndjson' + ) + end + + run_test! + end + + response '503', 'enqueue failed' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + before do + import_session.attach_chunk!( + sequence: 1, + client_chunk_id: 'docs-entities', + content: entity_ndjson, + filename: 'entities.ndjson', + content_type: 'application/x-ndjson' + ) + end + + around do |example| + ImportSessionJob.stub(:perform_later, ->(_import_session) { raise StandardError, 'queue offline' }) do + example.run + end + end + + run_test! + end + + response '404', 'import session not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:id) { SecureRandom.uuid } + + run_test! + end + end + end +end diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb index c17ec07f2..22c3dd0cc 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -1172,6 +1172,68 @@ RSpec.configure do |config| data: { '$ref' => '#/components/schemas/ImportDetail' } } }, + ImportSessionChunk: { + type: :object, + required: %w[id sequence status rows_count summary created_at updated_at], + properties: { + id: { type: :string, format: :uuid }, + sequence: { type: :integer, minimum: 1 }, + client_chunk_id: { type: :string, nullable: true }, + status: { type: :string, enum: %w[pending importing complete failed] }, + rows_count: { type: :integer, minimum: 0 }, + summary: { + type: :object, + additionalProperties: { + type: :object, + additionalProperties: { type: :integer } + } + }, + error: { + type: :object, + nullable: true, + additionalProperties: true + }, + created_at: { type: :string, format: :'date-time' }, + updated_at: { type: :string, format: :'date-time' } + } + }, + ImportSession: { + type: :object, + required: %w[id type status chunks_count summary chunks created_at updated_at], + properties: { + id: { type: :string, format: :uuid }, + type: { type: :string, enum: %w[SureImport] }, + status: { type: :string, enum: %w[pending importing complete failed] }, + client_session_id: { type: :string, nullable: true }, + expected_chunks: { type: :integer, nullable: true, minimum: 1 }, + chunks_count: { type: :integer, minimum: 0 }, + summary: { + type: :object, + additionalProperties: { + type: :object, + additionalProperties: { type: :integer } + } + }, + error: { + type: :object, + nullable: true, + additionalProperties: true + }, + chunks: { + type: :array, + items: { '$ref' => '#/components/schemas/ImportSessionChunk' } + }, + created_at: { type: :string, format: :'date-time' }, + updated_at: { type: :string, format: :'date-time' } + } + }, + ImportSessionResponse: { + type: :object, + required: %w[data], + properties: { + data: { '$ref' => '#/components/schemas/ImportSession' } + } + }, ProviderConnectionInstitution: { type: :object, required: %w[name], diff --git a/test/controllers/api/v1/import_sessions_controller_test.rb b/test/controllers/api/v1/import_sessions_controller_test.rb new file mode 100644 index 000000000..3a90f361d --- /dev/null +++ b/test/controllers/api/v1/import_sessions_controller_test.rb @@ -0,0 +1,308 @@ +# frozen_string_literal: true + +require "test_helper" + +class Api::V1::ImportSessionsControllerTest < ActionDispatch::IntegrationTest + include ActiveJob::TestHelper + + setup do + @user = users(:family_admin) + @family = @user.family + @api_key = api_keys(:active_key) + @read_only_api_key = api_keys(:one) + + Redis.new.del("api_rate_limit:#{@api_key.id}") + Redis.new.del("api_rate_limit:#{@read_only_api_key.id}") + end + + test "creates an idempotent Sure import session" do + assert_difference("ImportSession.count", 1) do + post api_v1_import_sessions_url, + params: { + type: "SureImport", + client_session_id: "client-session-1", + expected_chunks: 2 + }, + headers: api_headers(@api_key) + end + + assert_response :created + first_id = JSON.parse(response.body).dig("data", "id") + + assert_no_difference("ImportSession.count") do + post api_v1_import_sessions_url, + params: { + type: "SureImport", + client_session_id: "client-session-1", + expected_chunks: 2 + }, + headers: api_headers(@api_key) + end + + assert_response :created + assert_equal first_id, JSON.parse(response.body).dig("data", "id") + end + + test "rejects unsupported import session types" do + assert_no_difference("ImportSession.count") do + post api_v1_import_sessions_url, + params: { type: "TransactionImport" }, + headers: api_headers(@api_key) + end + + assert_response :unprocessable_entity + assert_equal "validation_failed", JSON.parse(response.body)["error"] + end + + test "rejects malformed expected chunk counts" do + assert_no_difference("ImportSession.count") do + post api_v1_import_sessions_url, + params: { type: "SureImport", expected_chunks: "2abc" }, + headers: api_headers(@api_key) + end + + assert_response :unprocessable_entity + assert_equal "validation_failed", JSON.parse(response.body)["error"] + end + + test "requires authentication for session creation" do + post api_v1_import_sessions_url, params: { type: "SureImport" } + + assert_response :unauthorized + assert_equal "unauthorized", JSON.parse(response.body)["error"] + end + + test "uploads ordered chunks and publishes a full-fidelity transaction import" do + session = build_import_session + + post chunks_api_v1_import_session_url(session), + params: { + sequence: 1, + client_chunk_id: "entities", + raw_file_content: build_ndjson(entity_records) + }, + headers: api_headers(@api_key) + + assert_response :created + assert_equal 1, JSON.parse(response.body).dig("data", "chunks_count") + + post chunks_api_v1_import_session_url(session), + params: { + sequence: 2, + client_chunk_id: "transactions", + raw_file_content: build_ndjson(transaction_records) + }, + headers: api_headers(@api_key) + + assert_response :created + + perform_enqueued_jobs do + post publish_api_v1_import_session_url(session), headers: api_headers(@api_key) + end + + assert_response :accepted + session.reload + assert session.complete? + assert_equal 1, session.summary.dig("transactions", "created") + + entry = @family.accounts.find_by!(name: "API Session Checking").entries.find_by!(name: "API Grocery Run") + transaction = entry.entryable + assert_equal "API Groceries", transaction.category.name + assert_equal "API Market", transaction.merchant.name + assert_equal [ "API Weekly" ], transaction.tags.map(&:name) + end + + test "rejects replayed chunk with different content" do + session = build_import_session + params = { + sequence: 1, + client_chunk_id: "entities", + raw_file_content: build_ndjson(entity_records) + } + + post chunks_api_v1_import_session_url(session), params: params, headers: api_headers(@api_key) + assert_response :created + + post chunks_api_v1_import_session_url(session), + params: params.merge(raw_file_content: build_ndjson(transaction_records)), + headers: api_headers(@api_key) + + assert_response :conflict + assert_equal "import_session_conflict", JSON.parse(response.body)["error"] + end + + test "requires chunk sequence" do + session = build_import_session + + post chunks_api_v1_import_session_url(session), + params: { raw_file_content: build_ndjson(entity_records) }, + headers: api_headers(@api_key) + + assert_response :bad_request + assert_equal "bad_request", JSON.parse(response.body)["error"] + end + + test "rejects malformed chunk sequence values" do + session = build_import_session + + post chunks_api_v1_import_session_url(session), + params: { sequence: "1abc", raw_file_content: build_ndjson(entity_records) }, + headers: api_headers(@api_key) + + assert_response :conflict + assert_equal "import_session_conflict", JSON.parse(response.body)["error"] + end + + test "shows import session with read scope" do + session = build_import_session + + get api_v1_import_session_url(session), headers: api_headers(@read_only_api_key) + + assert_response :success + data = JSON.parse(response.body)["data"] + assert_equal session.id, data["id"] + assert_equal "SureImport", data["type"] + end + + test "shows chunks in sequence order" do + session = build_import_session + session.imports.create!( + family: @family, + type: "SureImport", + sequence: 2, + checksum: Digest::SHA256.hexdigest("two") + ) + session.imports.create!( + family: @family, + type: "SureImport", + sequence: 1, + checksum: Digest::SHA256.hexdigest("one") + ) + + get api_v1_import_session_url(session), headers: api_headers(@api_key) + + assert_response :success + assert_equal [ 1, 2 ], JSON.parse(response.body).dig("data", "chunks").map { |chunk| chunk["sequence"] } + end + + test "requires write scope for session mutation" do + assert_no_difference("ImportSession.count") do + post api_v1_import_sessions_url, + params: { type: "SureImport" }, + headers: api_headers(@read_only_api_key) + end + + assert_response :forbidden + assert_equal "insufficient_scope", JSON.parse(response.body)["error"] + end + + test "rejects publishing a session with no chunks" do + session = @family.import_sessions.create! + + post publish_api_v1_import_session_url(session), headers: api_headers(@api_key) + + assert_response :conflict + assert_equal "import_session_conflict", JSON.parse(response.body)["error"] + end + + test "returns stable error when publish cannot enqueue" do + session = build_import_session + session.attach_chunk!( + sequence: 1, + content: build_ndjson(entity_records), + filename: "entities.ndjson", + content_type: "application/x-ndjson" + ) + session.attach_chunk!( + sequence: 2, + content: build_ndjson(transaction_records), + filename: "transactions.ndjson", + content_type: "application/x-ndjson" + ) + + ImportSessionJob.stub(:perform_later, ->(_import_session) { raise StandardError, "redis://secret.local/0" }) do + post publish_api_v1_import_session_url(session), headers: api_headers(@api_key) + end + + assert_response :service_unavailable + body = JSON.parse(response.body) + assert_equal "import_enqueue_failed", body["error"] + assert_equal "Import session could not be queued.", body["message"] + assert_no_match(/secret/, response.body) + end + + test "does not expose another family's import session" do + other_family = Family.create!(name: "Other Family", currency: "USD", locale: "en") + other_session = other_family.import_sessions.create! + + get api_v1_import_session_url(other_session), headers: api_headers(@api_key) + + assert_response :not_found + end + + private + def build_import_session + @family.import_sessions.create!(expected_chunks: 2) + end + + def entity_records + [ + { + type: "Account", + data: { + id: "api-acct-1", + name: "API Session Checking", + balance: "100.00", + currency: "USD", + accountable_type: "Depository" + } + }, + { + type: "Category", + data: { + id: "api-cat-1", + name: "API Groceries", + color: "#407706", + classification: "expense" + } + }, + { + type: "Merchant", + data: { + id: "api-merchant-1", + name: "API Market" + } + }, + { + type: "Tag", + data: { + id: "api-tag-1", + name: "API Weekly" + } + } + ] + end + + def transaction_records + [ + { + type: "Transaction", + data: { + id: "api-txn-1", + account_id: "api-acct-1", + category_id: "api-cat-1", + merchant_id: "api-merchant-1", + tag_ids: [ "api-tag-1" ], + date: "2024-01-15", + amount: "-12.34", + currency: "USD", + name: "API Grocery Run" + } + } + ] + end + + def build_ndjson(records) + records.map(&:to_json).join("\n") + end +end diff --git a/test/controllers/api/v1/users_controller_test.rb b/test/controllers/api/v1/users_controller_test.rb index 570c0bd9d..32d69c5f9 100644 --- a/test/controllers/api/v1/users_controller_test.rb +++ b/test/controllers/api/v1/users_controller_test.rb @@ -116,6 +116,20 @@ class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest end test "reset status returns family data counts" do + import_session = @user.family.import_sessions.create!(expected_chunks: 1) + import_session.imports.create!( + family: @user.family, + type: "SureImport", + sequence: 1, + checksum: "a" * 64 + ) + import_session.source_mappings.create!( + family: @user.family, + source_type: "Category", + source_id: "source-category-1", + target: @user.family.categories.first + ) + get "/api/v1/users/reset/status", headers: api_headers(@read_only_api_key) assert_response :ok @@ -124,6 +138,8 @@ class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest assert_includes %w[complete data_remaining], body["status"] assert_equal body["counts"].values.sum.zero?, body["reset_complete"] assert_equal expected_reset_count_keys.sort, body["counts"].keys.sort + assert_equal 1, body["counts"]["import_sessions"] + assert_equal 1, body["counts"]["import_source_mappings"] end test "reset status ignores the follow-up family sync after reset" do diff --git a/test/jobs/family_reset_job_test.rb b/test/jobs/family_reset_job_test.rb index d5979f3f7..739df4b60 100644 --- a/test/jobs/family_reset_job_test.rb +++ b/test/jobs/family_reset_job_test.rb @@ -10,10 +10,25 @@ class FamilyResetJobTest < ActiveJob::TestCase test "resets family data successfully" do initial_account_count = @family.accounts.count initial_category_count = @family.categories.count + import_session = @family.import_sessions.create!(expected_chunks: 1) + import_session.imports.create!( + family: @family, + type: "SureImport", + sequence: 1, + checksum: "a" * 64 + ) + import_session.source_mappings.create!( + family: @family, + source_type: "Category", + source_id: "source-category-1", + target: @family.categories.first + ) # Family should have existing data assert initial_account_count > 0 assert initial_category_count > 0 + assert_equal 1, @family.import_sessions.count + assert_equal 1, @family.import_source_mappings.count # Don't expect Plaid removal calls since we're using fixtures without setup @plaid_provider.stubs(:remove_item) @@ -23,6 +38,38 @@ class FamilyResetJobTest < ActiveJob::TestCase # All data should be removed assert_equal 0, @family.accounts.reload.count assert_equal 0, @family.categories.reload.count + assert_equal 0, @family.import_sessions.reload.count + assert_equal 0, @family.import_source_mappings.reload.count + assert_equal 0, @family.imports.reload.count + end + + test "reset leaves another family's imports and mappings untouched" do + other_family = Family.create!(name: "Other Family", currency: "USD", locale: "en") + other_category = other_family.categories.create!(name: "Other Category") + other_session = other_family.import_sessions.create!(expected_chunks: 1) + other_import = other_session.imports.create!( + family: other_family, + type: "SureImport", + sequence: 1, + checksum: "b" * 64 + ) + other_mapping = other_session.source_mappings.create!( + family: other_family, + source_type: "Category", + source_id: "source-category-1", + target: other_category + ) + + @family.import_sessions.create!(expected_chunks: 1) + @plaid_provider.stubs(:remove_item) + + FamilyResetJob.perform_now(@family) + + assert ImportSession.exists?(other_session.id) + assert Import.exists?(other_import.id) + assert ImportSourceMapping.exists?(other_mapping.id) + assert Category.exists?(other_category.id) + assert_equal other_category, other_mapping.reload.target end test "resets family data even when Plaid credentials are invalid" do diff --git a/test/models/family/data_exporter_test.rb b/test/models/family/data_exporter_test.rb index 83e4ea8cc..00d4759d7 100644 --- a/test/models/family/data_exporter_test.rb +++ b/test/models/family/data_exporter_test.rb @@ -381,9 +381,12 @@ class Family::DataExporterTest < ActiveSupport::TestCase assert rule_lines.any? - rule_data = JSON.parse(rule_lines.first) + rule_data = rule_lines.map { |line| JSON.parse(line) }.find { |rule| rule["data"]["name"] == "Test Rule" } + + assert_not_nil rule_data assert_equal "Rule", rule_data["type"] assert_equal 1, rule_data["version"] + assert_equal @rule.id, rule_data["data"]["id"] assert rule_data["data"].key?("name") assert rule_data["data"].key?("resource_type") assert rule_data["data"].key?("active") diff --git a/test/models/family/data_importer_test.rb b/test/models/family/data_importer_test.rb index 0ad0c9870..21ea0d4b7 100644 --- a/test/models/family/data_importer_test.rb +++ b/test/models/family/data_importer_test.rb @@ -125,6 +125,26 @@ class Family::DataImporterTest < ActiveSupport::TestCase assert_equal 1, balance.flows_factor end + test "counts skipped balance rows with blank account references once" do + ndjson = build_ndjson([ + { + type: "Balance", + data: { + id: "balance-1", + account_id: "", + date: "2024-01-31", + balance: "1200.00", + currency: "USD" + } + } + ]) + + result = Family::DataImporter.new(@family, ndjson).import! + + assert_equal 1, result.dig(:summary, "balances", "skipped") + assert_not Balance.exists?(date: Date.iso8601("2024-01-31"), currency: "USD", balance: BigDecimal("1200.00")) + end + test "imports duplicate raw balance records idempotently by account date and currency" do balance_record = { type: "Balance", @@ -442,6 +462,23 @@ class Family::DataImporterTest < ActiveSupport::TestCase assert_equal "#FF0000", tag.color end + test "imports tags with deterministic fallback color when source omits color" do + ndjson = build_ndjson([ + { + type: "Tag", + data: { + id: "tag-1", + name: "Important" + } + } + ]) + + Family::DataImporter.new(@family, ndjson).import! + + tag = @family.tags.find_by!(name: "Important") + assert_equal Tag::COLORS.first, tag.color + end + test "imports merchants" do ndjson = build_ndjson([ { @@ -945,6 +982,98 @@ class Family::DataImporterTest < ActiveSupport::TestCase assert_empty explicit_empty_child.transaction.tags end + test "session transaction reimport only replaces current family taggings" do + session = @family.import_sessions.create!(expected_chunks: 1) + account = @family.accounts.create!( + name: "Session Checking", + accountable: Depository.new, + balance: 100, + currency: "USD" + ) + original_tag = @family.tags.create!(name: "Original") + replacement_tag = @family.tags.create!(name: "Replacement") + entry = account.entries.create!( + date: Date.parse("2024-01-01"), + amount: -10, + currency: "USD", + name: "Original transaction", + source: "sure_import_session:#{session.id}", + external_id: "Transaction:txn-1", + entryable: Transaction.new(kind: "standard") + ) + transaction = entry.entryable + transaction.taggings.create!(tag: original_tag) + + other_family = Family.create!(name: "Other Family", currency: "USD") + other_session = other_family.import_sessions.create!(expected_chunks: 1) + other_account = other_family.accounts.create!( + name: "Other Checking", + accountable: Depository.new, + balance: 100, + currency: "USD" + ) + other_tag = other_family.tags.create!(name: "Other Original") + other_entry = other_account.entries.create!( + date: Date.parse("2024-01-01"), + amount: -10, + currency: "USD", + name: "Other transaction", + source: "sure_import_session:#{other_session.id}", + external_id: "Transaction:txn-1", + entryable: Transaction.new(kind: "standard") + ) + other_transaction = other_entry.entryable + other_transaction.taggings.create!(tag: other_tag) + + other_session.source_mappings.create!( + family: other_family, + source_type: "Transaction", + source_id: "txn-1", + target: other_transaction + ) + + session.source_mappings.create!( + family: @family, + source_type: "Account", + source_id: "acct-1", + target: account + ) + session.source_mappings.create!( + family: @family, + source_type: "Tag", + source_id: "tag-1", + target: replacement_tag + ) + session.source_mappings.create!( + family: @family, + source_type: "Transaction", + source_id: "txn-1", + target: transaction + ) + + ndjson = build_ndjson([ + { + type: "Transaction", + data: { + id: "txn-1", + account_id: "acct-1", + tag_ids: [ "tag-1" ], + date: "2024-02-01", + amount: "-12.34", + currency: "USD", + name: "Updated transaction" + } + } + ]) + + Family::DataImporter.new(@family, ndjson, import_session: session).import! + + assert_equal [ "Replacement" ], transaction.reload.tags.map(&:name) + assert_equal "Updated transaction", entry.reload.name + assert_equal [ "Other Original" ], other_transaction.reload.tags.map(&:name) + assert_equal "Other transaction", other_entry.reload.name + end + test "imports trades with securities" do ndjson = build_ndjson([ { @@ -1429,7 +1558,7 @@ class Family::DataImporterTest < ActiveSupport::TestCase amount: "100.00", name: "Transfer to savings", currency: "USD", - kind: "funds_movement" + kind: "standard" } }, { @@ -1441,7 +1570,7 @@ class Family::DataImporterTest < ActiveSupport::TestCase amount: "-100.00", name: "Transfer from checking", currency: "USD", - kind: "funds_movement" + kind: "standard" } }, { @@ -1496,6 +1625,8 @@ class Family::DataImporterTest < ActiveSupport::TestCase assert_equal "Confirmed by user", transfer.notes assert_equal "Transfer from checking", transfer.inflow_transaction.entry.name assert_equal "Transfer to savings", transfer.outflow_transaction.entry.name + assert_equal "funds_movement", transfer.inflow_transaction.kind + assert_equal "funds_movement", transfer.outflow_transaction.kind rejected_transfer = RejectedTransfer .joins(inflow_transaction: :entry) @@ -1711,6 +1842,61 @@ class Family::DataImporterTest < ActiveSupport::TestCase assert_equal category.id, action.value end + test "session rule reimport only replaces current family conditions and actions" do + rule = @family.rules.build(name: "Original Rule", resource_type: "transaction", active: true) + rule.conditions.build(condition_type: "transaction_name", operator: "like", value: "old") + rule.actions.build(action_type: "auto_categorize") + rule.save! + + other_family = Family.create!(name: "Other Rules Family", currency: "USD") + other_rule = other_family.rules.build(name: "Other Rule", resource_type: "transaction", active: true) + other_rule.conditions.build(condition_type: "transaction_name", operator: "like", value: "other-old") + other_rule.actions.build(action_type: "auto_categorize") + other_rule.save! + + other_session = other_family.import_sessions.create!(expected_chunks: 1) + other_session.source_mappings.create!( + family: other_family, + source_type: "Rule", + source_id: "rule-1", + target: other_rule + ) + + session = @family.import_sessions.create!(expected_chunks: 1) + session.source_mappings.create!( + family: @family, + source_type: "Rule", + source_id: "rule-1", + target: rule + ) + + ndjson = build_ndjson([ + { + type: "Rule", + version: 1, + data: { + id: "rule-1", + name: "Updated Rule", + resource_type: "transaction", + active: true, + conditions: [ + { condition_type: "transaction_name", operator: "like", value: "new" } + ], + actions: [ + { action_type: "set_transaction_name", value: "Renamed" } + ] + } + } + ]) + + Family::DataImporter.new(@family, ndjson, import_session: session).import! + + assert_equal [ "new" ], rule.reload.conditions.map(&:value) + assert_equal [ "set_transaction_name" ], rule.actions.map(&:action_type) + assert_equal [ "other-old" ], other_rule.reload.conditions.map(&:value) + assert_equal [ "auto_categorize" ], other_rule.actions.map(&:action_type) + end + test "imports rules from normalized operand value refs" do ndjson = build_ndjson([ { diff --git a/test/models/import_session_test.rb b/test/models/import_session_test.rb new file mode 100644 index 000000000..d1b80df4b --- /dev/null +++ b/test/models/import_session_test.rb @@ -0,0 +1,809 @@ +require "test_helper" + +class ImportSessionTest < ActiveSupport::TestCase + setup do + @family = families(:empty) + end + + test "job requires import session" do + error = assert_raises(ArgumentError) do + ImportSessionJob.perform_now(nil) + end + + assert_equal "ImportSessionJob requires an import_session", error.message + end + + test "job publishes import session" do + import_session = @family.import_sessions.create! + import_session.expects(:publish).once + + ImportSessionJob.perform_now(import_session) + end + + test "publishes ordered chunks with source mappings across files" do + session = @family.import_sessions.create!(expected_chunks: 2) + session.attach_chunk!( + sequence: 1, + client_chunk_id: "entities", + content: build_ndjson(entity_records), + filename: "entities.ndjson", + content_type: "application/x-ndjson" + ) + session.attach_chunk!( + sequence: 2, + client_chunk_id: "transactions", + content: build_ndjson(transaction_records), + filename: "transactions.ndjson", + content_type: "application/x-ndjson" + ) + + session.publish + + assert session.reload.complete? + account = @family.accounts.find_by!(name: "Session Checking") + entry = account.entries.find_by!(name: "Grocery Run") + transaction = entry.entryable + + assert_equal "Groceries", transaction.category.name + assert_equal "Market", transaction.merchant.name + assert_equal [ "Weekly" ], transaction.tags.map(&:name) + assert_equal "sure_import_session:#{session.id}", entry.source + assert_equal "Transaction:txn-1", entry.external_id + assert_equal 1, session.summary.dig("transactions", "created") + + assert_source_mapping session, "Account", "acct-1", account + assert_source_mapping session, "Category", "cat-1", transaction.category + assert_source_mapping session, "Merchant", "merchant-1", transaction.merchant + assert_source_mapping session, "Tag", "tag-1", transaction.tags.first + assert_source_mapping session, "Transaction", "txn-1", transaction + end + + test "publishing session chunks records readback verification for each chunk" do + session = @family.import_sessions.create!(expected_chunks: 2) + session.attach_chunk!( + sequence: 1, + content: build_ndjson(entity_records), + filename: "entities.ndjson", + content_type: "application/x-ndjson" + ) + session.attach_chunk!( + sequence: 2, + content: build_ndjson(transaction_records), + filename: "transactions.ndjson", + content_type: "application/x-ndjson" + ) + + session.publish + + entity_chunk, transaction_chunk = session.imports.ordered_by_sequence.to_a + assert_equal 1, entity_chunk.expected_record_counts["accounts"] + assert_equal 1, transaction_chunk.expected_record_counts["transactions"] + assert_includes SureImport::VERIFICATION_STATUSES, entity_chunk.readback_verification["status"] + assert_equal 1, entity_chunk.readback_verification.dig("checked_counts", "accounts") + assert_equal 1, transaction_chunk.readback_verification.dig("checked_counts", "transactions") + assert_equal 1, transaction_chunk.readback_verification.dig("actual_delta_counts", "transactions") + end + + test "publishing the same complete session does not duplicate imported transactions" do + session = @family.import_sessions.create!(expected_chunks: 2) + session.attach_chunk!( + sequence: 1, + content: build_ndjson(entity_records), + filename: "entities.ndjson", + content_type: "application/x-ndjson" + ) + session.attach_chunk!( + sequence: 2, + content: build_ndjson(transaction_records), + filename: "transactions.ndjson", + content_type: "application/x-ndjson" + ) + + session.publish + + assert_no_difference("Entry.count") do + session.publish + end + end + + test "republishing failed session skips complete chunks and retries failed chunks" do + session = @family.import_sessions.create!(expected_chunks: 2) + session.attach_chunk!( + sequence: 1, + content: build_ndjson(entity_records), + filename: "entities.ndjson", + content_type: "application/x-ndjson" + ) + session.attach_chunk!( + sequence: 2, + content: build_ndjson(transaction_records), + filename: "transactions.ndjson", + content_type: "application/x-ndjson" + ) + complete_chunk = session.imports.find_by!(sequence: 1) + failed_chunk = session.imports.find_by!(sequence: 2) + complete_chunk.update!(status: :complete, summary: { "accounts" => { "created" => 1 } }, error_details: {}) + failed_chunk.update!(status: :failed, error: "transient failure", error_details: { "code" => "import_failed" }) + session.update!( + status: :failed, + summary: complete_chunk.summary, + error_details: { "code" => "import_failed", "message" => "transient failure" } + ) + processed_sequences = [] + + importer_factory = lambda do |_family, _content, import_session:, import:| + processed_sequences << import.sequence + flunk "completed chunk was reprocessed" if import.sequence == 1 + assert_equal session, import_session + + Object.new.tap do |importer| + importer.define_singleton_method(:import!) do + { + accounts: [], + entries: [], + summary: { "transactions" => { "created" => 1 } } + } + end + end + end + + Family::DataImporter.stub(:new, importer_factory) do + session.publish + end + + assert_equal [ 2 ], processed_sequences + assert complete_chunk.reload.complete? + assert failed_chunk.reload.complete? + assert session.reload.complete? + assert_equal 1, session.summary.dig("accounts", "created") + assert_equal 1, session.summary.dig("transactions", "created") + end + + test "publish keeps session complete and records safe error when family sync enqueue fails" do + session = @family.import_sessions.create!(expected_chunks: 1) + session.attach_chunk!( + sequence: 1, + content: build_ndjson(entity_records), + filename: "entities.ndjson", + content_type: "application/x-ndjson" + ) + + Family.any_instance.stubs(:sync_later).raises(StandardError, "redis://secret.local/0") + session.publish + + assert session.reload.complete? + assert_equal "family_sync_enqueue_failed", session.error_details["code"] + assert_equal "Family sync could not be queued after import completion.", session.error_details["message"] + assert_no_match(/secret/, session.error_details.to_json) + end + + test "publish stores generic error details for unexpected import failures" do + session = @family.import_sessions.create!(expected_chunks: 1) + session.attach_chunk!( + sequence: 1, + content: build_ndjson(entity_records), + filename: "entities.ndjson", + content_type: "application/x-ndjson" + ) + + importer_factory = ->(*) { raise StandardError, "redis://secret.local/0" } + + Family::DataImporter.stub(:new, importer_factory) do + session.publish + end + + assert session.reload.failed? + assert_equal "Import session failed.", session.imports.first.error + assert_equal "import_failed", session.error_details["code"] + assert_equal "Import session failed.", session.error_details["message"] + assert_no_match(/secret/, session.error_details.to_json) + end + + test "publish later requires the exact expected chunk sequences" do + session = @family.import_sessions.create!(expected_chunks: 2) + session.attach_chunk!( + sequence: 1, + content: build_ndjson(entity_records), + filename: "entities.ndjson", + content_type: "application/x-ndjson" + ) + + error = assert_raises(ImportSession::ConflictError) do + session.publish_later + end + + expected_message = "import session chunks do not match expected sequences " \ + "(missing sequences: 2)" + assert_equal expected_message, error.message + assert session.reload.pending? + end + + test "chunk upload rejects sequences beyond the expected chunk count" do + session = @family.import_sessions.create!(expected_chunks: 1) + + error = assert_raises(ImportSession::ConflictError) do + session.attach_chunk!( + sequence: 2, + content: build_ndjson(entity_records), + filename: "entities.ndjson", + content_type: "application/x-ndjson" + ) + end + + assert_equal "sequence exceeds expected_chunks", error.message + assert_empty session.imports + end + + test "publish later restores status and records enqueue failures" do + session = @family.import_sessions.create!(expected_chunks: 1) + session.attach_chunk!( + sequence: 1, + content: build_ndjson(entity_records), + filename: "entities.ndjson", + content_type: "application/x-ndjson" + ) + + ImportSessionJob.stub(:perform_later, ->(_import_session) { raise StandardError, "queue offline" }) do + error = assert_raises(ImportSession::EnqueueError) do + session.publish_later + end + + assert_equal "Import session could not be queued.", error.message + end + + assert session.reload.pending? + assert_equal "import_enqueue_failed", session.error_details["code"] + assert_equal "Import session could not be queued.", session.error_details["message"] + end + + test "publish later syncs chunk row counts before enforcing row limit" do + session = @family.import_sessions.create!(expected_chunks: 1) + session.attach_chunk!( + sequence: 1, + content: build_ndjson(entity_records + transaction_records), + filename: "session.ndjson", + content_type: "application/x-ndjson" + ) + session.imports.update_all(rows_count: 0) + + SureImport.stub(:max_row_count, 1) do + assert_raises(Import::MaxRowCountExceededError) { session.publish_later } + end + + assert session.reload.pending? + assert_equal 5, session.imports.reload.first.rows_count + end + + test "fails loudly when a later chunk references a missing source id" do + session = @family.import_sessions.create!(expected_chunks: 1) + session.attach_chunk!( + sequence: 1, + content: build_ndjson(transaction_records), + filename: "transactions.ndjson", + content_type: "application/x-ndjson" + ) + + session.publish + + assert session.reload.failed? + chunk = session.imports.first + assert chunk.failed? + assert_equal "missing_source_reference", chunk.error_details["code"] + assert_equal "acct-1", chunk.error_details["source_id"] + assert_equal 0, @family.entries.count + end + + test "source mappings from another family cannot satisfy missing references" do + other_family = Family.create!(name: "Other Family", currency: "USD", locale: "en") + other_session = other_family.import_sessions.create!(expected_chunks: 1) + other_session.attach_chunk!( + sequence: 1, + content: build_ndjson(entity_records), + filename: "other-entities.ndjson", + content_type: "application/x-ndjson" + ) + other_session.publish + + assert other_session.reload.complete? + assert_equal 1, other_session.source_mappings.where(source_type: "Account", source_id: "acct-1").count + + session = @family.import_sessions.create!(expected_chunks: 1) + session.attach_chunk!( + sequence: 1, + content: build_ndjson(transaction_records), + filename: "transactions.ndjson", + content_type: "application/x-ndjson" + ) + + session.publish + + assert session.reload.failed? + assert_equal "missing_source_reference", session.imports.first.error_details["code"] + assert_equal "acct-1", session.imports.first.error_details["source_id"] + assert_equal 0, @family.entries.count + end + + test "session mode rejects invalid account accountable types" do + session = @family.import_sessions.create!(expected_chunks: 1) + session.attach_chunk!( + sequence: 1, + content: build_ndjson([ + { + type: "Account", + data: { + id: "acct-invalid", + name: "Invalid Account", + balance: "100.00", + currency: "USD", + accountable_type: "Kernel" + } + } + ]), + filename: "accounts.ndjson", + content_type: "application/x-ndjson" + ) + + session.publish + + assert session.reload.failed? + assert_equal 0, @family.accounts.count + assert_equal "invalid_import_record", session.imports.first.error_details["code"] + assert_equal "Account", session.imports.first.error_details["record_type"] + assert_equal "accountable_type", session.imports.first.error_details["field"] + assert_equal "Kernel", session.imports.first.error_details["value"] + end + + test "chunk upload is idempotent by sequence and checksum" do + session = @family.import_sessions.create! + content = build_ndjson(entity_records) + + first = session.attach_chunk!( + sequence: 1, + content: content, + filename: "entities.ndjson", + content_type: "application/x-ndjson" + ) + second = session.attach_chunk!( + sequence: 1, + content: content, + filename: "entities.ndjson", + content_type: "application/x-ndjson" + ) + + assert_equal first.id, second.id + assert_raises(ImportSession::ConflictError) do + session.attach_chunk!( + sequence: 1, + content: build_ndjson(transaction_records), + filename: "different.ndjson", + content_type: "application/x-ndjson" + ) + end + end + + test "chunk upload repairs incomplete existing chunk before accepting retry" do + session = @family.import_sessions.create! + content = build_ndjson(transaction_records) + chunk = session.imports.create!( + family: @family, + type: "SureImport", + sequence: 1, + client_chunk_id: "entities", + checksum: Digest::SHA256.hexdigest(content) + ) + + result = session.attach_chunk!( + sequence: 1, + client_chunk_id: "entities", + content: content, + filename: "entities.ndjson", + content_type: "application/x-ndjson" + ) + + assert_equal chunk.id, result.id + assert result.reload.ndjson_file.attached? + assert_equal 1, result.rows_count + end + + test "chunk upload resyncs attached existing chunk before accepting retry" do + session = @family.import_sessions.create! + content = build_ndjson(transaction_records) + chunk = session.imports.create!( + family: @family, + type: "SureImport", + sequence: 1, + client_chunk_id: "entities", + checksum: Digest::SHA256.hexdigest(content) + ) + chunk.ndjson_file.attach( + io: StringIO.new(content), + filename: "entities.ndjson", + content_type: "application/x-ndjson" + ) + + result = session.attach_chunk!( + sequence: 1, + client_chunk_id: "entities", + content: content, + filename: "entities.ndjson", + content_type: "application/x-ndjson" + ) + + assert_equal chunk.id, result.id + assert_equal 1, result.rows_count + end + + test "chunk upload rejects inconsistent sequence and client chunk keys" do + session = @family.import_sessions.create! + session.attach_chunk!( + sequence: 1, + client_chunk_id: "entities", + content: build_ndjson(entity_records), + filename: "entities.ndjson", + content_type: "application/x-ndjson" + ) + session.attach_chunk!( + sequence: 2, + client_chunk_id: "transactions", + content: build_ndjson(transaction_records), + filename: "transactions.ndjson", + content_type: "application/x-ndjson" + ) + + error = assert_raises(ImportSession::ConflictError) do + session.attach_chunk!( + sequence: 1, + client_chunk_id: "transactions", + content: build_ndjson(transaction_records), + filename: "transactions.ndjson", + content_type: "application/x-ndjson" + ) + end + + assert_equal "sequence and client_chunk_id refer to different chunks", error.message + end + + test "chunk upload treats duplicate insert races as idempotent retries" do + session = @family.import_sessions.create! + content = build_ndjson(entity_records) + existing = session.imports.create!( + family: @family, + type: "SureImport", + sequence: 1, + client_chunk_id: "entities", + checksum: Digest::SHA256.hexdigest(content) + ) + existing.ndjson_file.attach( + io: StringIO.new(content), + filename: "entities.ndjson", + content_type: "application/x-ndjson" + ) + existing.sync_ndjson_rows_count! + lookup_count = 0 + + session.stub(:existing_chunk_for!, ->(**) { + lookup_count += 1 + lookup_count == 1 ? nil : existing + }) do + session.stub(:create_chunk!, ->(**) { raise ActiveRecord::RecordNotUnique, "duplicate chunk" }) do + assert_equal existing, session.attach_chunk!( + sequence: 1, + client_chunk_id: "entities", + content: content, + filename: "entities.ndjson", + content_type: "application/x-ndjson" + ) + end + end + + assert_equal 2, lookup_count + end + + test "client session creation treats duplicate insert races as idempotent retries" do + existing = @family.import_sessions.create!(client_session_id: "race-session", expected_chunks: 2) + ImportSession.any_instance.stubs(:save!).raises(ActiveRecord::RecordNotUnique) + + session = ImportSession.create_or_find_for!( + family: @family, + import_type: "SureImport", + client_session_id: "race-session", + expected_chunks: 2 + ) + + assert_equal existing, session + end + + test "client session creation race backfills missing expected chunks" do + existing = @family.import_sessions.create!(client_session_id: "race-session") + racing_session = @family.import_sessions.build(client_session_id: "race-session") + racing_session.stubs(:save!).raises(ActiveRecord::RecordNotUnique) + + @family.import_sessions.stub(:find_or_initialize_by, racing_session) do + session = ImportSession.create_or_find_for!( + family: @family, + import_type: "SureImport", + client_session_id: "race-session", + expected_chunks: 2 + ) + + assert_equal existing, session + end + assert_equal 2, existing.reload.expected_chunks + end + + test "client session creation race preserves expected chunks conflict" do + @family.import_sessions.create!(client_session_id: "race-session", expected_chunks: 2) + ImportSession.any_instance.stubs(:save!).raises(ActiveRecord::RecordNotUnique) + + error = assert_raises(ImportSession::ConflictError) do + ImportSession.create_or_find_for!( + family: @family, + import_type: "SureImport", + client_session_id: "race-session", + expected_chunks: 3 + ) + end + + assert_equal "client_session_id already exists with a different expected_chunks value", error.message + end + + test "session mode rejects rule records without source ids" do + session = @family.import_sessions.create!(expected_chunks: 1) + session.attach_chunk!( + sequence: 1, + content: build_ndjson([ + { + type: "Rule", + data: { + name: "Missing Source Rule", + resource_type: "transaction", + active: true + } + } + ]), + filename: "rules.ndjson", + content_type: "application/x-ndjson" + ) + + session.publish + + assert session.reload.failed? + assert_equal 0, @family.rules.count + assert_equal "missing_source_reference", session.imports.first.error_details["code"] + assert_equal "Rule", session.imports.first.error_details["record_type"] + assert_equal "(blank)", session.imports.first.error_details["source_id"] + end + + test "session mode imports rule records exported by Sure packages" do + source_family = Family.create!(name: "Rule Export Source", currency: "USD", locale: "en") + category = source_family.categories.create!( + name: "Exported Category", + color: "#00AA00", + lucide_icon: "shapes" + ) + source_rule = source_family.rules.build( + name: "Exported Rule", + resource_type: "transaction", + active: true + ) + source_rule.conditions.build( + condition_type: "transaction_name", + operator: "like", + value: "Coffee" + ) + source_rule.actions.build( + action_type: "set_transaction_category", + value: category.id + ) + source_rule.save! + + session = @family.import_sessions.create!(expected_chunks: 1) + session.attach_chunk!( + sequence: 1, + content: exported_ndjson_for(source_family), + filename: "all.ndjson", + content_type: "application/x-ndjson" + ) + + session.publish + + assert session.reload.complete? + imported_rule = @family.rules.find_by!(name: "Exported Rule") + imported_category = @family.categories.find_by!(name: "Exported Category") + + assert_equal 1, session.summary.dig("rules", "created") + assert_equal imported_category.id, imported_rule.actions.first.value + assert_source_mapping session, "Rule", source_rule.id, imported_rule + end + + test "client idempotency keys are bounded before indexed writes" do + session = @family.import_sessions.build(client_session_id: "x" * 256) + + assert_not session.valid? + assert_includes session.errors[:client_session_id], "is too long (maximum is 255 characters)" + + import = @family.imports.build(type: "SureImport", client_chunk_id: "x" * 256) + + assert_not import.valid? + assert_includes import.errors[:client_chunk_id], "is too long (maximum is 255 characters)" + + import.sequence = 0 + import.checksum = "short" + + assert_not import.valid? + assert_includes import.errors[:sequence], "must be greater than 0" + assert_includes import.errors[:checksum], "is the wrong length (should be 64 characters)" + + other_family = Family.create!(name: "Other Import Family", currency: "USD", locale: "en") + import.import_session = other_family.import_sessions.build + import.sequence = nil + import.checksum = nil + + assert_not import.valid? + assert_includes import.errors[:import_session], "must belong to your family" + assert_includes import.errors[:sequence], "must be present for import session chunks" + assert_includes import.errors[:checksum], "must be present for import session chunks" + + mapping = @family.import_source_mappings.build( + import_session: @family.import_sessions.build, + source_type: "x" * 65, + source_id: "x" * 256, + target_type: "Account", + target_id: SecureRandom.uuid + ) + + assert_not mapping.valid? + assert_includes mapping.errors[:source_type], "is too long (maximum is 64 characters)" + assert_includes mapping.errors[:source_id], "is too long (maximum is 255 characters)" + + mapping.source_type = "Unsupported" + mapping.source_id = "acct-1" + + assert_not mapping.valid? + assert_includes mapping.errors[:source_type], "is not included in the list" + + mapping.source_type = "Account" + mapping.target_type = "Unsupported" + + assert_not mapping.valid? + assert_includes mapping.errors[:target_type], "is not included in the list" + end + + test "client idempotency keys are stripped before validation" do + session = @family.import_sessions.create!(client_session_id: " session-1 ") + import = @family.imports.create!(type: "SureImport", client_chunk_id: " chunk-1 ") + category = @family.categories.create!(name: "Mapping Category") + mapping = session.source_mappings.create!( + family: @family, + source_type: "Category", + source_id: " cat-1 ", + target: category + ) + + assert_equal "session-1", session.client_session_id + assert_equal "chunk-1", import.client_chunk_id + assert_equal "cat-1", mapping.source_id + end + + test "session status payloads must remain JSON objects" do + session = @family.import_sessions.build(summary: [], error_details: "failed") + import = @family.imports.build(type: "SureImport", summary: [], error_details: "failed") + + assert_not session.valid? + assert_includes session.errors[:summary], "must be an object" + assert_includes session.errors[:error_details], "must be an object" + + assert_not import.valid? + assert_includes import.errors[:summary], "must be an object" + assert_includes import.errors[:error_details], "must be an object" + end + + test "source mappings must belong to the same family as their import session" do + other_family = Family.create!(name: "Other Mapping Family", currency: "USD", locale: "en") + mapping = other_family.import_source_mappings.build( + import_session: @family.import_sessions.build, + source_type: "Account", + source_id: "acct-1", + target: @family.accounts.build(name: "Session Checking") + ) + + assert_not mapping.valid? + assert_includes mapping.errors[:family], "must match import session" + end + + test "source mapping targets must not cross family boundaries" do + other_family = Family.create!(name: "Other Mapping Target Family", currency: "USD", locale: "en") + mapping = @family.import_source_mappings.build( + import_session: @family.import_sessions.build, + source_type: " Account ", + source_id: "acct-1", + target: other_family.accounts.build(name: "Other Checking") + ) + + assert_not mapping.valid? + assert_equal "Account", mapping.source_type + assert_includes mapping.errors[:target], "must belong to your family" + end + + private + def entity_records + [ + { + type: "Account", + data: { + id: "acct-1", + name: "Session Checking", + balance: "100.00", + currency: "USD", + accountable_type: "Depository", + accountable: { subtype: "checking" } + } + }, + { + type: "Category", + data: { + id: "cat-1", + name: "Groceries", + color: "#407706", + classification: "expense" + } + }, + { + type: "Merchant", + data: { + id: "merchant-1", + name: "Market", + color: "#111111" + } + }, + { + type: "Tag", + data: { + id: "tag-1", + name: "Weekly", + color: "#222222" + } + } + ] + end + + def transaction_records + [ + { + type: "Transaction", + data: { + id: "txn-1", + account_id: "acct-1", + category_id: "cat-1", + merchant_id: "merchant-1", + tag_ids: [ "tag-1" ], + date: "2024-01-15", + amount: "-12.34", + currency: "USD", + name: "Grocery Run" + } + } + ] + end + + def build_ndjson(records) + records.map(&:to_json).join("\n") + end + + def exported_ndjson_for(family) + ndjson = nil + + Zip::File.open_buffer(Family::DataExporter.new(family).generate_export) do |zip| + ndjson = zip.read("all.ndjson") + end + + ndjson + end + + def assert_source_mapping(session, source_type, source_id, target) + mapping = session.source_mappings.find_by!(source_type: source_type, source_id: source_id) + + assert_equal @family, mapping.family + assert_equal target, mapping.target + end +end From 0d32f7507c82cb656de8b42909399f0d2037082a Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Thu, 4 Jun 2026 11:52:28 +0200 Subject: [PATCH 09/20] fix(goals): scope funding-account picker to the current user's accessible accounts (#2172) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(goals): scope funding-account picker to the current user's accessible accounts The new/edit goal funding picker and the linkable-account count queried `Current.family.accounts`, so it listed (and would link/fund from) every depository account in the family — including accounts owned by other members that aren't shared with the current user. Switch the three queries (index count, lookup, picker list) to `Current.user.accessible_accounts`, matching the access boundary used elsewhere. Adds controller tests covering the new-form picker and the create path rejecting a non-accessible same-family account. Fixes #2168 * fix(goals): preserve inaccessible linked accounts on goal edit The funding picker only renders Current.user.accessible_accounts, so a family goal linked to another member's private account renders no checkbox for it. On update, sync_linked_accounts! treated that omission as an intentional removal and destroyed the link the editor could not see. Restrict unlinking to the editor's accessible accounts so links outside their access are preserved. Adds a regression test. --- app/controllers/goals_controller.rb | 14 +++-- test/controllers/goals_controller_test.rb | 63 +++++++++++++++++++++++ 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/app/controllers/goals_controller.rb b/app/controllers/goals_controller.rb index c54a44371..7406f8fe4 100644 --- a/app/controllers/goals_controller.rb +++ b/app/controllers/goals_controller.rb @@ -26,7 +26,7 @@ class GoalsController < ApplicationController # entirely (rendered with filterable: false). @grid_goals = @active_goals + @completed_goals - @linkable_account_count = Current.family.accounts.where(accountable_type: "Depository").visible.count + @linkable_account_count = Current.user.accessible_accounts.where(accountable_type: "Depository").visible.count @kpi = kpi_payload(@active_goals) @any_pending_pledge = @active_goals.any? { |g| g.open_pledges.any? } @show_search = @grid_goals.size > 6 @@ -169,18 +169,24 @@ class GoalsController < ApplicationController return [] if ids.blank? ids = Array(ids).reject(&:blank?) - Current.family.accounts.where(accountable_type: "Depository").visible.where(id: ids).to_a + Current.user.accessible_accounts.where(accountable_type: "Depository").visible.where(id: ids).to_a end def linkable_accounts_for_new - Current.family.accounts.where(accountable_type: "Depository").visible.alphabetically.to_a + Current.user.accessible_accounts.where(accountable_type: "Depository").visible.alphabetically.to_a end def sync_linked_accounts!(goal, accounts) desired_ids = accounts.map(&:id).to_set current_ids = goal.goal_accounts.pluck(:account_id).to_set - (current_ids - desired_ids).each do |id| + # Only unlink accounts the current user can actually see in the picker. + # A family goal may be linked to another member's private account, which + # never renders as a checkbox — so its absence from the submitted set is + # not an intentional removal and must not destroy the link. + removable_ids = Current.user.accessible_accounts.where(id: current_ids.to_a).pluck(:id).to_set + + ((current_ids & removable_ids) - desired_ids).each do |id| goal.goal_accounts.where(account_id: id).destroy_all end additions = accounts.reject { |a| current_ids.include?(a.id) } diff --git a/test/controllers/goals_controller_test.rb b/test/controllers/goals_controller_test.rb index c20ae7e73..2fb08236c 100644 --- a/test/controllers/goals_controller_test.rb +++ b/test/controllers/goals_controller_test.rb @@ -90,6 +90,47 @@ class GoalsControllerTest < ActionDispatch::IntegrationTest assert_response :unprocessable_entity end + test "new form excludes same-family accounts not shared with the current user" do + # Regression for #2168: funding-account picker leaked accounts owned by + # other family members that were never shared with the current user. + private_account = Account.create!( + family: @user.family, + owner: users(:family_member), + accountable: Depository.new, + name: "Member Private Checking", + currency: "USD", + balance: 100 + ) + + get new_goal_url + assert_response :success + assert_no_match(/Member Private Checking/, response.body) + assert_no_match(/goal_account_ids_#{private_account.id}/, response.body) + end + + test "create rejects a same-family account not shared with the current user" do + private_account = Account.create!( + family: @user.family, + owner: users(:family_member), + accountable: Depository.new, + name: "Member Private Checking", + currency: "USD", + balance: 100 + ) + + assert_no_difference "Goal.count" do + post goals_url, params: { + goal: { + name: "Sneaky goal", + target_amount: "1000", + color: "#4da568", + account_ids: [ private_account.id ] + } + } + end + assert_response :unprocessable_entity + end + test "update modifies identity fields" do patch goal_url(@goal), params: { goal: { name: "Renamed" } } assert_redirected_to goal_path(@goal) @@ -109,6 +150,28 @@ class GoalsControllerTest < ActionDispatch::IntegrationTest assert_equal [ @connected.id ], @goal.reload.goal_accounts.pluck(:account_id) end + test "update preserves a linked account the current user cannot access" do + # Regression for #2172 review: a family goal can be linked to a private + # account owned by another member. That account is never rendered in the + # picker, so its absence from the submitted set must not unlink it. + private_account = Account.create!( + family: @user.family, + owner: users(:family_member), + accountable: Depository.new, + name: "Member Private Checking", + currency: @goal.currency, + balance: 100 + ) + @goal.goal_accounts.create!(account: private_account) + + patch goal_url(@goal), params: { goal: { account_ids: [ @depository.id ] } } + + assert_redirected_to goal_path(@goal) + linked = @goal.reload.goal_accounts.pluck(:account_id) + assert_includes linked, private_account.id, "inaccessible private link must be preserved" + assert_includes linked, @depository.id + end + test "update with empty account_ids re-renders with error" do patch goal_url(@goal), params: { goal: { account_ids: [ "" ] } } assert_response :unprocessable_entity From 1742f4ef1e3eef90e91a8b0c949bc89718ffea1b Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Thu, 4 Jun 2026 11:55:57 +0200 Subject: [PATCH 10/20] feat(ds): elevate dropdown overlays and stabilize selection check gutter (#2161) * feat(ds): elevate dropdown overlays and stabilize selection check gutter Menus and popovers floated at the same elevation as inline cards (shadow-border-xs), so dropdowns blended into the content beneath them. Bump DS::Menu and DS::Popover panels to shadow-border-lg. DS::MenuItem rendered its leading icon only when present, so a selection check shifted the row's text out of alignment with the unselected rows. Add a `selected:` param that reserves a fixed-width check gutter (check when selected, empty otherwise) so row text stays aligned. Apply the same reserved gutter to the bespoke category dropdown row, and add a `selectable` menu preview. * fix(ds): expose menu selection via menuitemradio + aria-checked Selectable DS::MenuItem rows conveyed selection only visually. Render them as role="menuitemradio" with aria-checked so assistive tech gets the selection state of single-select lists, merging the menu ARIA contract with any caller-supplied aria. Addresses CodeRabbit review feedback. * fix(ds): include selectable roles in menu roving-focus query DS::MenuItem selectable rows render as role=menuitemradio, but the menu controller built its roving-focus list from [role=menuitem] only, leaving single-select menus with no keyboard focus/arrow handling. Query the menuitemradio/menuitemcheckbox roles too. Addresses Codex review feedback. --- app/components/DS/menu.html.erb | 2 +- app/components/DS/menu_controller.js | 9 ++++- app/components/DS/menu_item.html.erb | 5 +++ app/components/DS/menu_item.rb | 35 ++++++++++++++++--- app/components/DS/popover.html.erb | 2 +- app/views/category/dropdowns/_row.html.erb | 4 ++- test/components/DS/menu_item_test.rb | 32 +++++++++++++++++ .../previews/menu_component_preview.rb | 12 +++++++ 8 files changed, 92 insertions(+), 9 deletions(-) create mode 100644 test/components/DS/menu_item_test.rb diff --git a/app/components/DS/menu.html.erb b/app/components/DS/menu.html.erb index c77895cbd..07828a01c 100644 --- a/app/components/DS/menu.html.erb +++ b/app/components/DS/menu.html.erb @@ -6,7 +6,7 @@ <% end %>